diff --git a/.agents/rules/schema-types.md b/.agents/rules/schema-types.md index 7b70dba19..588d2a841 100644 --- a/.agents/rules/schema-types.md +++ b/.agents/rules/schema-types.md @@ -27,7 +27,7 @@ modules** — files named `types.ts` (or `*.types.ts` under `configure/`): | `parser/service/tailordb/types.ts` | Parsed data structures (`TailorDBType`, `ParsedField`, permissions, ...) | | `parser/service/idp/types.ts` | Normalized IdP permission types shared by parser and CLI | | `plugin/types.ts` | Plugin authoring types, generation hook contexts, generator config | -| `runtime/types.ts` | Runtime principal/env types (`TailorUser`, `TailorActor`, `TailorEnv`) | +| `runtime/types.ts` | Runtime principal/env types (`TailorPrincipal`, `TailorEnv`) | Rules for pure type modules (enforced by oxlint): diff --git a/.agents/rules/sdk-internals.md b/.agents/rules/sdk-internals.md index 16bfe6904..a3db6590b 100644 --- a/.agents/rules/sdk-internals.md +++ b/.agents/rules/sdk-internals.md @@ -1,5 +1,20 @@ # SDK Internals +## API Naming: `define*` vs `create*` + +Public functions in `src/configure/` follow a strict naming convention. + +| Prefix | Meaning | Output | Where used | +| --------- | --------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `define*` | Declarative resource specification | Pure data/config struct (no SDK-managed runtime methods) | `tailor.config.ts` as resource entries | +| `create*` | Instantiation of a runtime-capable unit | Object with runtime-capable methods (`trigger`, `wait`, `resolve`) or user-written `body` function | Standalone files as default/named exports, or inside workflow job bodies | + +**`define*` examples:** `defineConfig`, `defineAuth`, `defineIdp`, `defineStaticWebSite`, `defineAIGateway`, `defineSecretManager`, `definePlugins` + +**`create*` examples:** `createResolver`, `createExecutor`, `createWorkflow`, `createWorkflowJob`, `createWaitPoint`, `createWaitPoints`, `createHttpAdapter` + +Decision rule: if the result has a method that calls the platform at runtime (`.trigger()`, `.wait()`, `.resolve()`, etc.) or carries a user-written `body` function, use `create*`. If the result is purely a typed config object that tooling/deployer reads, use `define*`. + ## Module Architecture and Import Rules The SDK enforces strict module boundaries to maintain a clean architecture: diff --git a/.agents/skills/e2e-test/AUTH.md b/.agents/skills/e2e-test/AUTH.md index fa52e04f6..e60f353d3 100644 --- a/.agents/skills/e2e-test/AUTH.md +++ b/.agents/skills/e2e-test/AUTH.md @@ -8,12 +8,12 @@ Official reference: [Tailor SDK user and login commands](https://docs.tailor.tec ## Authentication Order 1. If a trusted caller supplied `TAILOR_PLATFORM_TOKEN`, use it without printing or persisting it. -2. Otherwise run `pnpm exec tailor-sdk workspace list`. A config-backed user access token refreshes +2. Otherwise run `pnpm exec tailor workspace list`. A config-backed user access token refreshes automatically while its refresh token remains valid. 3. If the command reports `Failed to refresh token. Your session may have expired.`, do not assume expiry is the only cause; revocation, service errors, and network failures can produce the same message. Report the raw error. -4. When login recovery is required, ask the user to run `pnpm exec tailor-sdk login`. Browser login +4. When login recovery is required, ask the user to run `pnpm exec tailor login`. Browser login is a user-only action, then the agent can retry the original verification command. Never read, copy, print, persist, or edit the refresh token. An environment access token has no diff --git a/.agents/skills/e2e-test/SUITES.md b/.agents/skills/e2e-test/SUITES.md index bc9133492..872ad1e47 100644 --- a/.agents/skills/e2e-test/SUITES.md +++ b/.agents/skills/e2e-test/SUITES.md @@ -12,7 +12,7 @@ Confirm the saved workspace still hosts `my-app`: ```sh /bin/bash .agents/skills/e2e-test/scripts/with-e2e-ids.sh \ .agents/skills/e2e-test/ids.local.env -- \ - pnpm exec tailor-sdk workspace app list + pnpm exec tailor workspace app list ``` Treat a reused workspace as shared until its users and ownership are known. List its Platform users @@ -21,7 +21,7 @@ before any redeploy: ```sh /bin/bash .agents/skills/e2e-test/scripts/with-e2e-ids.sh \ .agents/skills/e2e-test/ids.local.env -- \ - pnpm exec tailor-sdk workspace user list + pnpm exec tailor workspace user list ``` If another person or automation may rely on it, ask the user before changing it. With a valid @@ -29,17 +29,17 @@ profile, run both checks with the same `TAILOR_PLATFORM_PROFILE` instead of the If the workspace is missing or no longer hosts the application: -1. Verify login with `pnpm exec tailor-sdk workspace list`. +1. Verify login with `pnpm exec tailor workspace list`. 2. Resolve existing organization and folder IDs only when they are not saved: ```sh - pnpm exec tailor-sdk organization list - pnpm exec tailor-sdk organization folder list --organization-id + pnpm exec tailor organization list + pnpm exec tailor organization folder list --organization-id ``` 3. Ask before creating a long-lived workspace. Use `example-e2e` unless it collides. 4. Save its ID to `ids.local.env`, set mode `0600`, deploy, and confirm `my-app`: ```sh TAILOR_PLATFORM_WORKSPACE_ID= pnpm --filter example run deploy - pnpm exec tailor-sdk workspace app list --workspace-id + pnpm exec tailor workspace app list --workspace-id ``` Use `run deploy`; pnpm's built-in `deploy` shadows the package script when `run` is omitted. @@ -70,8 +70,8 @@ This suite creates disposable workspaces and uses `TAILOR_PLATFORM_ORGANIZATION_ If either ID is missing, discover an existing value and save it to `ids.local.env` with mode `0600`: ```sh -pnpm exec tailor-sdk organization list -pnpm exec tailor-sdk organization folder list --organization-id +pnpm exec tailor organization list +pnpm exec tailor organization folder list --organization-id ``` Run through the wrapper so every workspace created for the run receives one namespace and cleanup is diff --git a/.agents/skills/e2e-test/scripts/run-sdk-e2e.sh b/.agents/skills/e2e-test/scripts/run-sdk-e2e.sh index 5a547c025..97c8b0936 100644 --- a/.agents/skills/e2e-test/scripts/run-sdk-e2e.sh +++ b/.agents/skills/e2e-test/scripts/run-sdk-e2e.sh @@ -62,7 +62,7 @@ fi test_pid="" trap - HUP INT TERM -"$cleanup_node" "$workspace_cleanup" "$run_id" -- pnpm exec tailor-sdk +"$cleanup_node" "$workspace_cleanup" "$run_id" -- pnpm exec tailor cleanup_status=$? echo "E2E test status: $test_status; cleanup status: $cleanup_status" >&2 diff --git a/.agents/skills/e2e-test/test/fixtures/bin/pnpm b/.agents/skills/e2e-test/test/fixtures/bin/pnpm index 32162f30a..7f97dd67c 100644 --- a/.agents/skills/e2e-test/test/fixtures/bin/pnpm +++ b/.agents/skills/e2e-test/test/fixtures/bin/pnpm @@ -22,7 +22,7 @@ if [[ ${1:-} == "run" && ${2:-} == "test:e2e" ]]; then exit "${E2E_TEST_STATUS:-0}" fi -if [[ ${1:-} == "exec" && ${2:-} == "tailor-sdk" ]]; then +if [[ ${1:-} == "exec" && ${2:-} == "tailor" ]]; then shift 2 exec "$E2E_FAKE_NODE" "$E2E_FAKE_CLI" "$@" fi diff --git a/.agents/skills/e2e-test/test/fixtures/fake-tailor-sdk.mjs b/.agents/skills/e2e-test/test/fixtures/fake-tailor.mjs similarity index 100% rename from .agents/skills/e2e-test/test/fixtures/fake-tailor-sdk.mjs rename to .agents/skills/e2e-test/test/fixtures/fake-tailor.mjs diff --git a/.agents/skills/e2e-test/test/skill.test.sh b/.agents/skills/e2e-test/test/skill.test.sh index 8bac394e2..f03f5859e 100644 --- a/.agents/skills/e2e-test/test/skill.test.sh +++ b/.agents/skills/e2e-test/test/skill.test.sh @@ -8,7 +8,7 @@ cleanup_helper="$skill_dir/scripts/cleanup-e2e-workspaces.mjs" ids_helper="$skill_dir/scripts/with-e2e-ids.sh" runner="$skill_dir/scripts/run-sdk-e2e.sh" fixtures="$skill_dir/test/fixtures" -fake_cli="$fixtures/fake-tailor-sdk.mjs" +fake_cli="$fixtures/fake-tailor.mjs" fake_pnpm_source="$fixtures/bin/pnpm" fail() { diff --git a/.agents/skills/llm-challenge/CREATING_PROBLEMS.md b/.agents/skills/llm-challenge/CREATING_PROBLEMS.md index 8fd74097d..dcdfaa96e 100644 --- a/.agents/skills/llm-challenge/CREATING_PROBLEMS.md +++ b/.agents/skills/llm-challenge/CREATING_PROBLEMS.md @@ -18,7 +18,7 @@ Rules: - `verify.json`, when present, contains visible minimum-correctness checks only. Checks should encode conditions where missing evidence is definitely wrong, similar to type checking; do not put ideal implementations, hidden answers, scores, or broad quality judgments there. - Write `prompt.md` in English. - For `sdk-api`, do not include SDK API names, imports, code examples, or direct solution hints. -- For `cli`, the prompt may name the `tailor-sdk` binary, but must not name the target subcommand or exact arguments. +- For `cli`, the prompt may name the `tailor` binary, but must not name the target subcommand or exact arguments. - Keep `scaffold/` minimal and runnable enough for the task. Do not add `solution/`, evaluator tests, scoring metadata, or hidden hints. Validate discovery and focused behavior with narrow tests or a targeted dry run when practical. diff --git a/.agents/skills/tailor b/.agents/skills/tailor new file mode 120000 index 000000000..2165e68b1 --- /dev/null +++ b/.agents/skills/tailor @@ -0,0 +1 @@ +../../packages/sdk/agent-skills/tailor \ No newline at end of file diff --git a/.agents/skills/tailor-sdk b/.agents/skills/tailor-sdk deleted file mode 120000 index 167d322cb..000000000 --- a/.agents/skills/tailor-sdk +++ /dev/null @@ -1 +0,0 @@ -../../packages/sdk/agent-skills/tailor-sdk \ No newline at end of file diff --git a/.changeset/add-erd-plugin-package.md b/.changeset/add-erd-plugin-package.md new file mode 100644 index 000000000..7e6c85d63 --- /dev/null +++ b/.changeset/add-erd-plugin-package.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-plugin-tailordb-erd": minor +--- + +New Tailor CLI plugin providing the `tailor tailordb erd` commands (export, diff, serve, deploy), extracted from `@tailor-platform/sdk`. diff --git a/.changeset/apply-deploy-source-strings.md b/.changeset/apply-deploy-source-strings.md new file mode 100644 index 000000000..bf47fb101 --- /dev/null +++ b/.changeset/apply-deploy-source-strings.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Rewrite `tailor-sdk apply` to `tailor-sdk deploy` in source files that contain embedded CLI command strings. diff --git a/.changeset/auth-attributes-rename.md b/.changeset/auth-attributes-rename.md new file mode 100644 index 000000000..45744f30f --- /dev/null +++ b/.changeset/auth-attributes-rename.md @@ -0,0 +1,7 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": patch +"@tailor-platform/sdk-codemod": patch +--- + +Rename auth attribute module augmentation from `AttributeMap` to `Attributes`. diff --git a/.changeset/cli-plugin-schema-api.md b/.changeset/cli-plugin-schema-api.md new file mode 100644 index 000000000..0cf677b15 --- /dev/null +++ b/.changeset/cli-plugin-schema-api.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add `loadTailorDBNamespaces`, `deployStaticWebsite`, `assertWritable`, and `isPluginGeneratedType` (with their related types) to `@tailor-platform/sdk/cli`, so CLI plugins and scripts can load local TailorDB schema data and deploy static websites through the public API. diff --git a/.changeset/cli-plugins.md b/.changeset/cli-plugins.md new file mode 100644 index 000000000..4e4f4b301 --- /dev/null +++ b/.changeset/cli-plugins.md @@ -0,0 +1,10 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add CLI plugin support (beta). Running `tailor ` for an unknown subcommand now executes an external `tailor-` executable found on your PATH or in `node_modules/.bin` (project-local takes precedence), forwarding all following arguments. This also works for unknown subcommands nested under a known command — e.g. `tailor tailordb erd` dispatches to `tailor-tailordb-erd`. Builtins always take precedence, matching stops at the first unknown segment, and a command that takes its own arguments is never replaced by a plugin. The plugin receives the current Tailor Platform context via environment variables (`TAILOR_PLATFORM_TOKEN`, `TAILOR_PLATFORM_URL`, `TAILOR_PLATFORM_OAUTH2_CLIENT_ID`, `TAILOR_PLATFORM_WORKSPACE_ID`, `TAILOR_PLATFORM_USER`, `TAILOR_CONFIG_PATH`, `TAILOR_VERSION`, `TAILOR_BIN`); token, workspace, and user are best-effort, so auth-free plugins still run when you are not logged in. When the forwarded arguments include an explicit `--profile`/`-p`, the injected context is resolved for that profile; when they include `--env-file`/`-e`/`--env-file-if-exists`, platform context injection is skipped so the env file's values take effect in the plugin. + +Also adds: + +- `tailor auth token` — print a valid access token (refreshing it if expired) for use by plugins and scripts. +- `tailor plugin list` — list discovered plugins and their executable paths. diff --git a/.changeset/codemod-llm-review.md b/.changeset/codemod-llm-review.md new file mode 100644 index 000000000..dafb3bbab --- /dev/null +++ b/.changeset/codemod-llm-review.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Add LLM-assisted review support to the codemod runner. A codemod can declare `suspiciousPatterns` plus a `prompt`; after running, files whose post-transform content still matches a suspicious pattern are reported as `llmReviews` (in the JSON output and on stderr) together with the codemod's migration prompt. This surfaces the cases a deterministic transform cannot safely complete (e.g. a value reached through a variable) so they can be finished with an LLM. The `auth.invoker(...)` codemod adopts this for its non-literal-argument cases. diff --git a/.changeset/codemod-migration-docs.md b/.changeset/codemod-migration-docs.md new file mode 100644 index 000000000..6efdcd67d --- /dev/null +++ b/.changeset/codemod-migration-docs.md @@ -0,0 +1,10 @@ +--- +"@tailor-platform/sdk-codemod": patch +"@tailor-platform/sdk": patch +--- + +Generate the v2 migration guide (`packages/sdk/docs/migration/v2.md`) from the codemod registry, which is the single source of truth. Each entry renders its name, automation level (Automatic / Partially automatic / Manual), description, optional before/after `examples`, and — for changes the codemods cannot fully migrate on their own — the LLM/manual migration prompt. Run `pnpm codemod:docs:update` to regenerate and `pnpm codemod:docs:check` (wired into `pnpm check`) to verify it is in sync. + +`scriptPath` is now optional, so the registry can also describe codemod-less ("manual") migrations that ship only guidance (`examples` / `prompt` / `suspiciousPatterns`) with no automatic transform. A manual entry with a `prompt` but no scoping pattern is surfaced as a project-wide `llmReviews` entry at runtime. + +Add a `sdk-codemod list` command that prints every registered rule (id, name, kind, version range). diff --git a/.changeset/codemod-residual-matching.md b/.changeset/codemod-residual-matching.md new file mode 100644 index 000000000..d62d97729 --- /dev/null +++ b/.changeset/codemod-residual-matching.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Reduce false-positive v2 codemod warnings and LLM-review prompts from source comments, string literals, and identifier substring matches. diff --git a/.changeset/codemod-runner-metadata.md b/.changeset/codemod-runner-metadata.md new file mode 100644 index 000000000..e2e85700b --- /dev/null +++ b/.changeset/codemod-runner-metadata.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Report the codemod runner identity in the JSON summary, including the source checkout commit and local build command when run from a branch build, so prerelease migration validation can distinguish exact npm packages from branch-head behavior. diff --git a/.changeset/db-table-builder.md b/.changeset/db-table-builder.md new file mode 100644 index 000000000..5774b3349 --- /dev/null +++ b/.changeset/db-table-builder.md @@ -0,0 +1,18 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Rename the TailorDB schema builder from `db.type()` to `db.table()`. + +Update TailorDB definitions: + +```diff + import { db } from "@tailor-platform/sdk"; + +-export const user = db.type("User", { ++export const user = db.table("User", { + name: db.string(), + }); +``` diff --git a/.changeset/execute-script-json-arg.md b/.changeset/execute-script-json-arg.md new file mode 100644 index 000000000..5342c615d --- /dev/null +++ b/.changeset/execute-script-json-arg.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +`executeScript` now takes its `arg` as a JSON-serializable value instead of a pre-serialized JSON string. Pass the value directly (e.g. `arg: { a: 1 }`) instead of `arg: JSON.stringify({ a: 1 })`. + +Add the `v2/execute-script-arg` codemod, which unwraps `JSON.stringify(...)` passed as the `executeScript` `arg` option. Indirect forms (a stringified value held in a variable, etc.) cannot be rewritten automatically and are surfaced as an LLM-assisted review task with a migration prompt. diff --git a/.changeset/execute-script-review-scope.md b/.changeset/execute-script-review-scope.md new file mode 100644 index 000000000..395507f08 --- /dev/null +++ b/.changeset/execute-script-review-scope.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Reduce noisy `executeScript` LLM-review prompts by flagging files only when unresolved `arg` stringification remains likely. diff --git a/.changeset/extract-erd-plugin.md b/.changeset/extract-erd-plugin.md new file mode 100644 index 000000000..1775b3359 --- /dev/null +++ b/.changeset/extract-erd-plugin.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove the built-in `tailordb erd` commands. They are now provided by the `@tailor-platform/sdk-plugin-tailordb-erd` CLI plugin: install it with `npm install -D @tailor-platform/sdk-plugin-tailordb-erd` and keep running `tailor tailordb erd ` as before — the CLI dispatches to the plugin automatically and suggests the install command when the plugin is missing. The `erdSite` TailorDB setting is unchanged. diff --git a/.changeset/field-parse-runtime-layer.md b/.changeset/field-parse-runtime-layer.md new file mode 100644 index 000000000..05205e384 --- /dev/null +++ b/.changeset/field-parse-runtime-layer.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix `tailor function test-run` crashing with `TypeError: Cannot convert undefined or null to object` when `--arg` is a non-object JSON value such as `null`. The argument is now forwarded to the server, which reports the validation error. Local input validation also runs the same logic as deployed resolvers. diff --git a/.changeset/fix-mockfile-open-download-stream.md b/.changeset/fix-mockfile-open-download-stream.md new file mode 100644 index 000000000..558d934dd --- /dev/null +++ b/.changeset/fix-mockfile-open-download-stream.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Remove the `openDownloadStream` method from `mockFile()` (`@tailor-platform/sdk/vitest`), matching the runtime file API, which no longer exposes it. Use `downloadStream` instead. diff --git a/.changeset/fix-next5-codemod-boundaries.md b/.changeset/fix-next5-codemod-boundaries.md new file mode 100644 index 000000000..9d0d9b68d --- /dev/null +++ b/.changeset/fix-next5-codemod-boundaries.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Fix `tailor upgrade` reporting zero codemods across several v2 prerelease boundaries. `v2/db-type-to-table` and `v2/runtime-subpath-namespace` now trigger at `2.0.0-next.4` (where they actually shipped) instead of `2.0.0-next.3`, and `v2/forward-relation-name`, `v2/tailordb-validate-simplify`, and `v2/tailordb-hook-redesign` now trigger at `2.0.0-next.5` instead of `2.0.0-next.4`. diff --git a/.changeset/fix-optional-attributes-permission-keys.md b/.changeset/fix-optional-attributes-permission-keys.md new file mode 100644 index 000000000..ae1aba50f --- /dev/null +++ b/.changeset/fix-optional-attributes-permission-keys.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix IdP and TailorDB permission condition types breaking when `Attributes` fields are optional. Since machine user attribute keys started mirroring the source field's optionality, the `user` operand key helpers leaked `undefined` into their key unions — failing typecheck against the generated permission types even for `_loggedIn`-only conditions — and rejected attribute keys derived from optional fields. Optional attribute fields are now valid operand keys and `undefined` no longer appears in the unions. diff --git a/.changeset/fix-rename-bin-source-files.md b/.changeset/fix-rename-bin-source-files.md new file mode 100644 index 000000000..0ce99f44c --- /dev/null +++ b/.changeset/fix-rename-bin-source-files.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Apply the v2 `rename-bin` codemod to SDK CLI command strings in TypeScript and JavaScript source files. diff --git a/.changeset/fix-ts-hook-dotted-basename.md b/.changeset/fix-ts-hook-dotted-basename.md new file mode 100644 index 000000000..25337b2d2 --- /dev/null +++ b/.changeset/fix-ts-hook-dotted-basename.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix `tailor` CLI failing with `ERR_MODULE_NOT_FOUND` when resolving extensionless relative imports of files whose basename contains a dot (e.g. `./permissions.generated`). diff --git a/.changeset/fix-ts-hook-paths-without-baseurl.md b/.changeset/fix-ts-hook-paths-without-baseurl.md new file mode 100644 index 000000000..4c6e0d51b --- /dev/null +++ b/.changeset/fix-ts-hook-paths-without-baseurl.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix `tailor` CLI failing with `ERR_MODULE_NOT_FOUND` when resolving `tsconfig.json` path aliases (`compilerOptions.paths`) in projects that omit `baseUrl`, which is the standard style since TypeScript 5.0. Also fix path alias resolution to match TypeScript's `extends` behavior: a child config's `baseUrl` is now correctly inherited from an extended config, and a child config's own `paths` now replaces (rather than merges with) inherited `paths`. diff --git a/.changeset/fix-v2-prerelease-codemods.md b/.changeset/fix-v2-prerelease-codemods.md new file mode 100644 index 000000000..90ba3dcf1 --- /dev/null +++ b/.changeset/fix-v2-prerelease-codemods.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Run v2 codemods when the target version is a v2 prerelease. diff --git a/.changeset/fix-validate-issues-generic-field.md b/.changeset/fix-validate-issues-generic-field.md new file mode 100644 index 000000000..4136e102a --- /dev/null +++ b/.changeset/fix-validate-issues-generic-field.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix a spurious `TS2719` type error when a `db.table()` type built with custom fields is passed across module boundaries (e.g. into another package's factory function) and its `.validate()` callback's `issues()` field parameter is compared for assignability. The `issues()` field argument type no longer uses a self-generic parameter, so structurally-equal `TailorDBType` instances derived through different generic instantiation paths are recognized as compatible. diff --git a/.changeset/forward-relation-name.md b/.changeset/forward-relation-name.md new file mode 100644 index 000000000..48ad9840c --- /dev/null +++ b/.changeset/forward-relation-name.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Derive default TailorDB forward relation names from the relation field name by removing a trailing `ID`, `Id`, or `id`, instead of deriving them from the target table name. + +The v2 migration review identifies non-self relations without `toward.as`. Add an explicit name to preserve the v1 GraphQL field name, or update consumers to use the new field-based name. diff --git a/.changeset/generate-watch-codemod.md b/.changeset/generate-watch-codemod.md new file mode 100644 index 000000000..2603c0ef6 --- /dev/null +++ b/.changeset/generate-watch-codemod.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Flag `tailor generate --watch` / `-W` invocations and programmatic `generate({ watch })` usage for manual review as part of the v2 migration (the flag and its dependency watcher are removed in `@tailor-platform/sdk` v2). diff --git a/.changeset/invoker-option-rename.md b/.changeset/invoker-option-rename.md new file mode 100644 index 000000000..a6ddc8fee --- /dev/null +++ b/.changeset/invoker-option-rename.md @@ -0,0 +1,9 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +"@tailor-platform/create-sdk": patch +--- + +Rename resolver, executor, workflow trigger, and typed workflow start machine-user options from `authInvoker` to `invoker`. + +Update create-sdk templates and the v2 auth invoker codemod to generate the new `invoker` option. diff --git a/.changeset/keyring-default-storage.md b/.changeset/keyring-default-storage.md new file mode 100644 index 000000000..b3beffd46 --- /dev/null +++ b/.changeset/keyring-default-storage.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Store CLI login tokens in the OS keyring by default when available. If the keyring is unavailable, tokens are stored in the platform config file. diff --git a/.changeset/kysely-date-timestamp.md b/.changeset/kysely-date-timestamp.md new file mode 100644 index 000000000..d38ee46d5 --- /dev/null +++ b/.changeset/kysely-date-timestamp.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix generated Kysely types for `db.date()` fields to use `Timestamp` instead of `string`, matching `db.datetime()` and allowing `insertInto`/`updateTable` calls to accept a `Date` value. diff --git a/.changeset/open-download-review-scope.md b/.changeset/open-download-review-scope.md new file mode 100644 index 000000000..baf39cabc --- /dev/null +++ b/.changeset/open-download-review-scope.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Limit the `openDownloadStream` migration review prompt to files that reference deprecated download stream APIs. diff --git a/.changeset/parser-schema-strict.md b/.changeset/parser-schema-strict.md new file mode 100644 index 000000000..f06690fcd --- /dev/null +++ b/.changeset/parser-schema-strict.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Reject unknown keys in SDK parser schemas instead of silently dropping them from application definitions. diff --git a/.changeset/politty-skill-command-cleanup.md b/.changeset/politty-skill-command-cleanup.md new file mode 100644 index 000000000..b0206a3e9 --- /dev/null +++ b/.changeset/politty-skill-command-cleanup.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Upgrade politty to 0.11.2 and simplify the `skills` command wiring to use its new `globalArgs`/`commandMap`/`unknownKeys` customization options instead of hand-rolled schema rewriting. diff --git a/.changeset/politty-skill-management.md b/.changeset/politty-skill-management.md new file mode 100644 index 000000000..0332a12c2 --- /dev/null +++ b/.changeset/politty-skill-management.md @@ -0,0 +1,6 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Replace `tailor skills install` with project-local `tailor skills add`, `list`, `remove`, and `sync` commands for bundled Tailor SDK agent skills. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 000000000..7166aaa78 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,86 @@ +{ + "mode": "pre", + "tag": "next", + "changesets": [ + "add-erd-plugin-package", + "apply-deploy-source-strings", + "auth-attributes-rename", + "calm-tools-lint", + "cli-plugin-schema-api", + "cli-plugins", + "codemod-llm-review", + "codemod-migration-docs", + "codemod-residual-matching", + "codemod-runner-metadata", + "db-table-builder", + "execute-script-json-arg", + "execute-script-review-scope", + "extract-erd-plugin", + "field-parse-runtime-layer", + "fix-mockfile-open-download-stream", + "fix-multi-config-cwd", + "fix-next5-codemod-boundaries", + "fix-optional-attributes-permission-keys", + "fix-rename-bin-source-files", + "fix-ts-hook-dotted-basename", + "fix-ts-hook-paths-without-baseurl", + "fix-user-file-tsconfig-paths", + "fix-v2-prerelease-codemods", + "fix-validate-issues-generic-field", + "forward-relation-name", + "generate-watch-codemod", + "invoker-option-rename", + "isolate-concurrent-deployment-bundles", + "keyring-default-storage", + "kysely-date-timestamp", + "machine-user-optional-attributes", + "open-download-review-scope", + "parser-schema-strict", + "politty-skill-command-cleanup", + "politty-skill-management", + "principal-followup-review", + "principal-unify-codemod", + "principal-unify-review-findings", + "principal-unify-review-followup", + "remove-auth-connection-token", + "remove-auth-invoker-helper", + "remove-define-generators", + "remove-function-test-run-input-wrapper", + "remove-generate-watch", + "remove-legacy-bundle-cleanup", + "remove-open-download-stream", + "remove-runtime-globals-compatibility", + "remove-tailor-sdk-skills-shim", + "remove-tailorctl-config-migration", + "remove-tsx", + "remove-v2-cli-aliases", + "remove-workflow-test-env-fallback", + "rename-bin-command", + "rename-erd-plugin-to-sdk-plugin-tailordb-erd", + "rename-tailor-cli-env", + "renovate-1785", + "require-function-log-content-hash", + "require-tailordb-permission-config", + "resolve-pending-codemod-boundaries", + "revert-strict-scalar-strings", + "runtime-global-source-strings", + "runtime-globals-import", + "runtime-idp-wrapper", + "runtime-subpath-namespace", + "share-codemod-runtime-imports", + "store-cli-users-by-subject", + "tailor-output-ignore-dir", + "tailor-principal-type", + "tailordb-shared-now-hook", + "timestamps-updated-at-create", + "update-erd-plugin-install-hint-name", + "user-profile-type-schema", + "v2-baseline", + "wait-point-rename", + "workflow-canonical-names", + "workflow-executor-args-contract", + "workflow-start-rename", + "workflow-trigger-dispatch", + "workflow-trigger-rename" + ] +} diff --git a/.changeset/principal-followup-review.md b/.changeset/principal-followup-review.md new file mode 100644 index 000000000..cd77e30a5 --- /dev/null +++ b/.changeset/principal-followup-review.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Flag files that need project-specific review after the v2 principal migration, including resolver helper adapters and nullable `caller` follow-ups. diff --git a/.changeset/principal-unify-codemod.md b/.changeset/principal-unify-codemod.md new file mode 100644 index 000000000..c2542022b --- /dev/null +++ b/.changeset/principal-unify-codemod.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": minor +--- + +Add the `v2/principal-unify` codemod so `tailor-sdk upgrade` can migrate SDK principal APIs to `TailorPrincipal`. diff --git a/.changeset/principal-unify-review-findings.md b/.changeset/principal-unify-review-findings.md new file mode 100644 index 000000000..c9bab5eff --- /dev/null +++ b/.changeset/principal-unify-review-findings.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Report precise file-local findings for `principal-unify` review follow-ups, including nullable caller call sites and `context.user` helper adapters. diff --git a/.changeset/principal-unify-review-followup.md b/.changeset/principal-unify-review-followup.md new file mode 100644 index 000000000..2dd4833ae --- /dev/null +++ b/.changeset/principal-unify-review-followup.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Fix `v2/principal-unify` review findings for nested SDK field parser invoker values and destructured context helper messages. diff --git a/.changeset/remove-auth-connection-token.md b/.changeset/remove-auth-connection-token.md new file mode 100644 index 000000000..e78da7822 --- /dev/null +++ b/.changeset/remove-auth-connection-token.md @@ -0,0 +1,6 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Remove the deprecated `auth.getConnectionToken()` helper from values returned by `defineAuth()`. Use `authconnection.getConnectionToken(...)` from `@tailor-platform/sdk/runtime` in resolvers, executors, and workflows instead. The v2 codemod rewrites direct `auth.getConnectionToken(...)` calls when the `auth` binding is imported from `tailor.config`. diff --git a/.changeset/remove-auth-invoker-helper.md b/.changeset/remove-auth-invoker-helper.md new file mode 100644 index 000000000..5c94a22e7 --- /dev/null +++ b/.changeset/remove-auth-invoker-helper.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove the deprecated `auth.invoker("")` helper. Pass machine user names directly as `authInvoker` strings in resolver, executor, and workflow APIs. diff --git a/.changeset/remove-define-generators.md b/.changeset/remove-define-generators.md new file mode 100644 index 000000000..bf2676d7f --- /dev/null +++ b/.changeset/remove-define-generators.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove `defineGenerators()` and legacy `generators` config support. Use `definePlugins()` with the built-in plugin packages for code generation. diff --git a/.changeset/remove-function-test-run-input-wrapper.md b/.changeset/remove-function-test-run-input-wrapper.md new file mode 100644 index 000000000..5341ba525 --- /dev/null +++ b/.changeset/remove-function-test-run-input-wrapper.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove support for wrapping `tailor-sdk function test-run --arg` resolver input in an `input` object. Pass resolver input fields directly as the `--arg` JSON value. diff --git a/.changeset/remove-generate-watch.md b/.changeset/remove-generate-watch.md new file mode 100644 index 000000000..44cd15ba8 --- /dev/null +++ b/.changeset/remove-generate-watch.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove the `generate --watch` (`-W`) flag. `tailor generate` now always runs a single generation pass; re-run the command after making changes instead of relying on the watcher to regenerate automatically. diff --git a/.changeset/remove-legacy-bundle-cleanup.md b/.changeset/remove-legacy-bundle-cleanup.md new file mode 100644 index 000000000..ab4c145f9 --- /dev/null +++ b/.changeset/remove-legacy-bundle-cleanup.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +`tailor deploy` no longer automatically deletes on-disk bundle artifacts left by SDK versions predating the in-memory bundling approach; delete those specific stale files manually if any remain from a very old SDK version (do not delete the whole output directory, which also holds deploy state such as secrets and Auth Connection records) diff --git a/.changeset/remove-open-download-stream.md b/.changeset/remove-open-download-stream.md new file mode 100644 index 000000000..e40b446c0 --- /dev/null +++ b/.changeset/remove-open-download-stream.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": patch +--- + +Remove the deprecated `openDownloadStream` file streaming API. Use `downloadStream` for streamed file downloads. + +The generated file utilities now emit `downloadFileStream`, which calls `downloadStream` and returns `FileDownloadStreamResponse`, instead of the removed `openFileDownloadStream` helper. diff --git a/.changeset/remove-runtime-globals-compatibility.md b/.changeset/remove-runtime-globals-compatibility.md new file mode 100644 index 000000000..1917f11ee --- /dev/null +++ b/.changeset/remove-runtime-globals-compatibility.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Remove the v1 runtime globals compatibility layer. Importing from `@tailor-platform/sdk` no longer activates the ambient `tailor.*` / `tailordb.*` declarations; opt into globals with `@tailor-platform/sdk/runtime/globals` or use the typed wrappers from `@tailor-platform/sdk/runtime`. + +The capital-cased `Tailordb.*` namespace is removed. If your project still references `Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, or `typeof Tailordb.Client`, migrate before upgrading: run `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` to rewrite them to lowercase `tailordb.*`, then add `import "@tailor-platform/sdk/runtime/globals"` so the rewritten references resolve. diff --git a/.changeset/remove-tailor-sdk-skills-shim.md b/.changeset/remove-tailor-sdk-skills-shim.md new file mode 100644 index 000000000..982a4f64e --- /dev/null +++ b/.changeset/remove-tailor-sdk-skills-shim.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove the deprecated `tailor-sdk-skills` binary shim. Use `tailor skills add` to install the bundled Tailor SDK agent skill. diff --git a/.changeset/remove-tailorctl-config-migration.md b/.changeset/remove-tailorctl-config-migration.md new file mode 100644 index 000000000..891110581 --- /dev/null +++ b/.changeset/remove-tailorctl-config-migration.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Stop importing credentials and profiles from legacy `~/.tailorctl/config` when the platform config is missing. New CLI configs now start empty in the current platform config format. diff --git a/.changeset/remove-tsx.md b/.changeset/remove-tsx.md new file mode 100644 index 000000000..3ca872d9c --- /dev/null +++ b/.changeset/remove-tsx.md @@ -0,0 +1,19 @@ +--- +"@tailor-platform/sdk": major +--- + +Minimum Node.js version raised to 22.15.0; TypeScript loading switched from tsx to amaro + +Removes `tsx` (which pulled in esbuild's native binaries, ~10.5 MB) from +`dependencies` and replaces it with `amaro` (~3.8 MB, zero transitive deps). + +A small `ts-hook.mjs` provides the Node.js module hook with both a resolver +and a load hook (`amaro` for full TypeScript support including enums). +The resolver handles `.ts` extension fallback, directory barrel imports +(`./models` → `./models/index.ts`), and tsconfig `paths` aliases (reads +`tsconfig.json` following `extends` chains). +Dev-only scripts now use `node --experimental-strip-types` instead. + +Raises the minimum Node.js version to 22.15.0 (from >=22) to use +`module.registerHooks()`, which allows synchronous hook registration directly +in the main thread without a worker thread. diff --git a/.changeset/remove-v2-cli-aliases.md b/.changeset/remove-v2-cli-aliases.md new file mode 100644 index 000000000..a5d3ebdc5 --- /dev/null +++ b/.changeset/remove-v2-cli-aliases.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Remove deprecated CLI aliases for the v2 command surface. Use `tailor-sdk deploy` instead of `tailor-sdk apply`, `tailor-sdk crashreport` instead of `tailor-sdk crash-report`, and the hyphenated `--machine-user` option instead of the hidden `--machineuser` alias. + +Fix the v2 CLI rename codemod to migrate the hidden `--machineuser` option to `--machine-user`. diff --git a/.changeset/remove-workflow-test-env-fallback.md b/.changeset/remove-workflow-test-env-fallback.md new file mode 100644 index 000000000..830daa5c0 --- /dev/null +++ b/.changeset/remove-workflow-test-env-fallback.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove the deprecated workflow test env fallback. `WORKFLOW_TEST_ENV_KEY` is no longer exported, and `TAILOR_TEST_WORKFLOW_ENV` is no longer read when running workflows locally. Use `mockWorkflow().setEnv(...)` or pass `{ env }` to `runWorkflowLocally(...)` instead. diff --git a/.changeset/rename-bin-command.md b/.changeset/rename-bin-command.md new file mode 100644 index 000000000..d696f409c --- /dev/null +++ b/.changeset/rename-bin-command.md @@ -0,0 +1,14 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Rename the CLI binary from `tailor-sdk` to `tailor`. + +The output directory default changes from `.tailor-sdk` to `.tailor`, and the GitHub Actions lock file path changes from `.github/tailor-sdk.lock` to `.github/tailor.lock`. + +Run the `v2/rename-bin` codemod to migrate `tailor-sdk` invocations in package.json scripts, shell scripts, CI workflows, and documentation: + +```sh +npx @tailor-platform/sdk-codemod --from 1.x --to 2.0.0 +``` diff --git a/.changeset/rename-erd-plugin-to-sdk-plugin-tailordb-erd.md b/.changeset/rename-erd-plugin-to-sdk-plugin-tailordb-erd.md new file mode 100644 index 000000000..d45b90547 --- /dev/null +++ b/.changeset/rename-erd-plugin-to-sdk-plugin-tailordb-erd.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-plugin-tailordb-erd": minor +--- + +Renamed the package from `@tailor-platform/sdk-tailordb-erd-plugin` to `@tailor-platform/sdk-plugin-tailordb-erd`, following the `eslint-plugin-*`-style naming convention used for CLI plugin packages. Update your dependency to the new name; the `tailor-tailordb-erd` executable and the `tailor tailordb erd` commands are unchanged. diff --git a/.changeset/rename-tailor-cli-env.md b/.changeset/rename-tailor-cli-env.md new file mode 100644 index 000000000..1fe743e4d --- /dev/null +++ b/.changeset/rename-tailor-cli-env.md @@ -0,0 +1,9 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": patch +"@tailor-platform/sdk-codemod": patch +--- + +Standardize SDK-owned environment variables on the `TAILOR_*` namespace. + +Replace the removed SDK-specific environment variables with their new names: `TAILOR_CONFIG_PATH`, `TAILOR_DTS_PATH`, `TAILOR_CI_ALLOW_ID_INJECTION`, `TAILOR_DEPLOY_BUILD_ONLY`, `TAILOR_BUILD_OUTPUT_DIR`, `TAILOR_SKILLS_SOURCE`, `TAILOR_TEMPLATE_SDK_VERSION`, `TAILOR_PLATFORM_URL`, `TAILOR_PLATFORM_OAUTH2_CLIENT_ID`, `TAILOR_INLINE_SOURCEMAP`, `TAILOR_QUERY_NEWLINE_ON_ENTER`, and `TAILOR_APP_LOG_LEVEL`. The deprecated `TAILOR_TOKEN` fallback is removed; use `TAILOR_PLATFORM_TOKEN`. The v2 codemod rewrites unambiguous removed SDK environment variable names and flags generic names such as `LOG_LEVEL` and `PLATFORM_URL` for manual review. diff --git a/.changeset/require-function-log-content-hash.md b/.changeset/require-function-log-content-hash.md new file mode 100644 index 000000000..e0b2c7cc5 --- /dev/null +++ b/.changeset/require-function-log-content-hash.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Require `FunctionExecution.contentHash` for `function logs` stack trace source mapping. Executions without a content hash now show raw stack traces instead of mapping against the current function bundle. diff --git a/.changeset/require-tailordb-permission-config.md b/.changeset/require-tailordb-permission-config.md new file mode 100644 index 000000000..e7dac0c96 --- /dev/null +++ b/.changeset/require-tailordb-permission-config.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fail fast at `generate`/`deploy` time when a TailorDB type is missing `.permission()`, or missing `.gqlPermission()` while GraphQL operations are enabled for it (`.gqlPermission()` is not required when GraphQL exposure is fully disabled via `gqlOperations`). Previously these omissions deployed silently and only surfaced later as an opaque `internal error` on insert. diff --git a/.changeset/resolve-pending-codemod-boundaries.md b/.changeset/resolve-pending-codemod-boundaries.md new file mode 100644 index 000000000..7a885d407 --- /dev/null +++ b/.changeset/resolve-pending-codemod-boundaries.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Add a `V2_NEXT_PENDING` placeholder `prereleaseUntil` for codemods whose exact `2.0.0-next.N` release boundary isn't known yet at implementation time, plus a `pnpm codemod:resolve-pending` step (wired into the release workflow) that resolves it to the real version constant once the release PR bumps `@tailor-platform/sdk`'s version. Prevents codemod boundaries from drifting out of sync with the version they actually ship in, which previously required a manual follow-up fix after each release. diff --git a/.changeset/revert-strict-scalar-strings.md b/.changeset/revert-strict-scalar-strings.md new file mode 100644 index 000000000..13da3f913 --- /dev/null +++ b/.changeset/revert-strict-scalar-strings.md @@ -0,0 +1,7 @@ +--- +"@tailor-platform/create-sdk": major +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Restore Tailor field outputs for UUID, date, datetime, time, and decimal fields to plain string-compatible types and remove the strict scalar string migration guidance. diff --git a/.changeset/runtime-global-source-strings.md b/.changeset/runtime-global-source-strings.md new file mode 100644 index 000000000..2b3d617b6 --- /dev/null +++ b/.changeset/runtime-global-source-strings.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Flag JavaScript files and embedded code strings that still reference ambient Tailor runtime globals during v2 migration review. diff --git a/.changeset/runtime-globals-import.md b/.changeset/runtime-globals-import.md new file mode 100644 index 000000000..de9e7cfc7 --- /dev/null +++ b/.changeset/runtime-globals-import.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Flag files that still reference ambient Tailor runtime globals so the v2 migration can opt them into `@tailor-platform/sdk/runtime/globals`. diff --git a/.changeset/runtime-idp-wrapper.md b/.changeset/runtime-idp-wrapper.md new file mode 100644 index 000000000..58c7001a3 --- /dev/null +++ b/.changeset/runtime-idp-wrapper.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Automatically migrate simple direct `tailor.idp.Client` runtime global usage to the typed `idp.Client` wrapper during v2 upgrades. diff --git a/.changeset/runtime-subpath-namespace.md b/.changeset/runtime-subpath-namespace.md new file mode 100644 index 000000000..3d02f66b0 --- /dev/null +++ b/.changeset/runtime-subpath-namespace.md @@ -0,0 +1,11 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +"@tailor-platform/create-sdk": patch +--- + +Remove flat value and default exports from `@tailor-platform/sdk/runtime/*` subpath modules. Import each subpath through its self-named namespace export instead, for example `import { iconv } from "@tailor-platform/sdk/runtime/iconv"`. + +The aggregate `@tailor-platform/sdk/runtime` entry remains named-only, and its deprecated `file.deleteFile` alias is removed in favor of `file.delete`. The v2 codemod rewrites straightforward namespace-star subpath imports, flat named value imports, and aggregate `file.deleteFile` calls to the new namespace-object style. + +`TailorContextAPI` and `TailorWorkflowAPI` now describe the SDK wrapper objects. Code that types the platform-provided `globalThis.tailor.context` or `globalThis.tailor.workflow` objects directly must use `PlatformContextAPI` or `PlatformWorkflowAPI` instead. diff --git a/.changeset/share-codemod-runtime-imports.md b/.changeset/share-codemod-runtime-imports.md new file mode 100644 index 000000000..5f1cfa1be --- /dev/null +++ b/.changeset/share-codemod-runtime-imports.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Keep v2 codemods from reusing type-only runtime helper imports when adding runtime value imports. diff --git a/.changeset/store-cli-users-by-subject.md b/.changeset/store-cli-users-by-subject.md new file mode 100644 index 000000000..715a5ccda --- /dev/null +++ b/.changeset/store-cli-users-by-subject.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Store CLI human users by stable subject ID in the platform config instead of email, while preserving email for display and migrating legacy email-keyed entries on login or token refresh. diff --git a/.changeset/tailor-output-ignore-dir.md b/.changeset/tailor-output-ignore-dir.md new file mode 100644 index 000000000..b46ae7149 --- /dev/null +++ b/.changeset/tailor-output-ignore-dir.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Add the `v2/tailor-output-ignore-dir` codemod so SDK upgrades rewrite exact `.tailor-sdk/` ignore-file entries to `.tailor/` while leaving other `.tailor-sdk` paths unchanged. diff --git a/.changeset/tailor-principal-type.md b/.changeset/tailor-principal-type.md new file mode 100644 index 000000000..e2946b79c --- /dev/null +++ b/.changeset/tailor-principal-type.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Unify function principal context around `TailorPrincipal`. + +Resolver contexts now use `caller` and `invoker` as `TailorPrincipal | null`, workflow and executor invokers also use `TailorPrincipal | null`, and event executor `actor` uses `TailorPrincipal | null` with `id`/`type` fields. The legacy `TailorUser`, `TailorInvoker`, `TailorActor`, `TailorActorType`, and `unauthenticatedTailorUser` exports are removed. diff --git a/.changeset/tailordb-shared-now-hook.md b/.changeset/tailordb-shared-now-hook.md new file mode 100644 index 000000000..ac5631282 --- /dev/null +++ b/.changeset/tailordb-shared-now-hook.md @@ -0,0 +1,15 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Redesign TailorDB hooks and validators with several breaking changes: + +- Add shared `now` timestamp to all hooks — multiple fields stamped with the same `Date` +- Field-level hooks: `{ value, data, invoker }` → create `{ input, invoker, now }` / update `{ input, oldValue, invoker, now }` (`data` removed, `oldValue` added for update only) +- Type-level hooks: per-field mapping (`Hooks`) → single `{ create, update }` object (`TypeHook`) returning partial field overrides +- Type-level create hooks no longer receive `oldRecord`; update hooks receive non-nullable `oldRecord` +- Field-level validators: return type changed from `boolean` to `string | void` (return error message or void to pass); `[fn, message]` tuple form removed +- Type-level validators: `Validators` per-field record → `TypeValidateFn` single function with `issues(field, message)` callback +- Add `.default(value)` on fields to set a create-time default (makes required fields optional in create input) +- Remove exported types: `Hooks`, `Validators`, `ValidateConfig` diff --git a/.changeset/timestamps-updated-at-create.md b/.changeset/timestamps-updated-at-create.md new file mode 100644 index 000000000..c88c6344b --- /dev/null +++ b/.changeset/timestamps-updated-at-create.md @@ -0,0 +1,10 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": major +--- + +Set `db.fields.timestamps()` `updatedAt` when records are created and make the generated field non-null. `createdAt` keeps its existing create-time behavior, while `updatedAt` keeps its update-time behavior and now also gets a create hook that preserves provided values and falls back to the current time. + +Update create-sdk templates so scaffolded projects use the new non-null `updatedAt` Kysely types and seed schemas. + +Existing TailorDB schemas that already use this helper will change `updatedAt` from optional to required. Backfill existing records that have `updatedAt: null` before applying the schema change. diff --git a/.changeset/update-erd-plugin-install-hint-name.md b/.changeset/update-erd-plugin-install-hint-name.md new file mode 100644 index 000000000..822610f79 --- /dev/null +++ b/.changeset/update-erd-plugin-install-hint-name.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Update the `tailordb erd` plugin install hint to reference the renamed `@tailor-platform/sdk-plugin-tailordb-erd` package. diff --git a/.changeset/user-profile-type-schema.md b/.changeset/user-profile-type-schema.md new file mode 100644 index 000000000..d5a8dc8da --- /dev/null +++ b/.changeset/user-profile-type-schema.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Validate auth user profile TailorDB types with the strict TailorDB parser schema. diff --git a/.changeset/v2-baseline.md b/.changeset/v2-baseline.md new file mode 100644 index 000000000..8f2e53ac9 --- /dev/null +++ b/.changeset/v2-baseline.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Start the v2 release line. v2 introduces breaking changes to the SDK API and CLI; run `tailor-sdk upgrade` to apply the bundled codemods when migrating. Prereleases are published to the `next` dist-tag — install with `@tailor-platform/sdk@next`. diff --git a/.changeset/wait-point-rename.md b/.changeset/wait-point-rename.md new file mode 100644 index 000000000..0621a005a --- /dev/null +++ b/.changeset/wait-point-rename.md @@ -0,0 +1,20 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Rename `defineWaitPoint` and `defineWaitPoints` to `createWaitPoint` and `createWaitPoints`. + +These functions create runtime instances with `.wait()` and `.resolve()` methods that call the platform API at runtime, so the `create*` prefix is more accurate. Update any usages: + +```diff +-import { defineWaitPoint, defineWaitPoints } from "@tailor-platform/sdk"; ++import { createWaitPoint, createWaitPoints } from "@tailor-platform/sdk"; + +-export const approval = defineWaitPoint("approval"); ++export const approval = createWaitPoint("approval"); + +-export const waitPoints = defineWaitPoints((define) => ({ ... })); ++export const waitPoints = createWaitPoints((define) => ({ ... })); +``` diff --git a/.changeset/workflow-canonical-names.md b/.changeset/workflow-canonical-names.md new file mode 100644 index 000000000..e446be535 --- /dev/null +++ b/.changeset/workflow-canonical-names.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add canonical `tailor.workflow.*` names that mirror the public `tailor.v1` RPC vocabulary. The `startWorkflow`, `startJobFunction`, and `resumeWorkflowExecution` methods are now available on `tailor.workflow`, alongside `workflow.startWorkflow` / `workflow.startJobFunction` / `workflow.resumeWorkflowExecution` from `@tailor-platform/sdk/runtime`. The pre-alignment names (`triggerWorkflow`, `triggerJobFunction`, `resumeWorkflow`) continue to work as frozen aliases, but are now marked `@deprecated` so IDEs surface a hint to migrate to the canonical names. diff --git a/.changeset/workflow-executor-args-contract.md b/.changeset/workflow-executor-args-contract.md new file mode 100644 index 000000000..804d9e924 --- /dev/null +++ b/.changeset/workflow-executor-args-contract.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Require workflow executor `args` when the target workflow input is required, and accept primitive and array static JSON inputs during configuration parsing. Existing workflow executor configurations that omit required input must add `args`. diff --git a/.changeset/workflow-start-rename.md b/.changeset/workflow-start-rename.md new file mode 100644 index 000000000..28c647e26 --- /dev/null +++ b/.changeset/workflow-start-rename.md @@ -0,0 +1,17 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Rename `Workflow.trigger()` (returned by `createWorkflow()`) and `WorkflowJob.trigger()` (returned by `createWorkflowJob()`) to `.start()`, aligning the SDK's ergonomic verb with the platform's `start*` RPC vocabulary: + +```diff + const inventory = checkInventory.trigger({ orderId: input.orderId }); ++const inventory = checkInventory.start({ orderId: input.orderId }); + +-const workflowRunId = await orderProcessingWorkflow.trigger(args, { invoker: "manager" }); ++const workflowRunId = await orderProcessingWorkflow.start(args, { invoker: "manager" }); +``` + +`mockWorkflow()`'s `wf.job(definition)` / `wf.workflow(definition)` now return a mock of the `.start` method, and `wf.setTriggerHandler` / `wf.triggeredJobs` are renamed to `wf.setStartHandler` / `wf.startedJobs`. No codemod ships for the `.trigger()` → `.start()` call-site rename itself — see the `v2/workflow-start-rename` migration guide entry for manual migration steps. diff --git a/.changeset/workflow-trigger-dispatch.md b/.changeset/workflow-trigger-dispatch.md new file mode 100644 index 000000000..bd6841bee --- /dev/null +++ b/.changeset/workflow-trigger-dispatch.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Align workflow job `.start()` (previously `.trigger()`) with the platform runtime. Job starts now require a mocked workflow runtime in tests instead of running job bodies locally, and `start()` returns the job result directly instead of a Promise wrapper. Use `mockWorkflow()` to mock start results in tests, or `runWorkflowLocally()` for full-chain local workflow tests. diff --git a/.changeset/workflow-trigger-rename.md b/.changeset/workflow-trigger-rename.md new file mode 100644 index 000000000..c9f260bea --- /dev/null +++ b/.changeset/workflow-trigger-rename.md @@ -0,0 +1,19 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Remove the pre-alignment `tailor.workflow` names `triggerWorkflow`, `triggerJobFunction`, and `resumeWorkflow` (and their `TriggerWorkflowOptions` / `TriggerJobFunctionOptions` option types) from `@tailor-platform/sdk/runtime`, the ambient `@tailor-platform/sdk/runtime/globals` types, and the `mockWorkflow()` test facade. Use the canonical names instead: + +```diff + import { workflow } from "@tailor-platform/sdk/runtime"; + +-await workflow.triggerWorkflow("myWorkflow", { data: "value" }); ++await workflow.startWorkflow("myWorkflow", { data: "value" }); +-workflow.triggerJobFunction("myJob", { data: "value" }); ++workflow.startJobFunction("myJob", { data: "value" }); +-await workflow.resumeWorkflow("execution-id"); ++await workflow.resumeWorkflowExecution("execution-id"); +``` + +Run the `v2/workflow-trigger-rename` codemod to migrate call sites automatically. diff --git a/.claude/rules/schema-types.md b/.claude/rules/schema-types.md index 7e4b505d9..2ae7685de 100644 --- a/.claude/rules/schema-types.md +++ b/.claude/rules/schema-types.md @@ -39,7 +39,7 @@ modules** — files named `types.ts` (or `*.types.ts` under `configure/`): | `parser/service/tailordb/types.ts` | Parsed data structures (`TailorDBType`, `ParsedField`, permissions, ...) | | `parser/service/idp/types.ts` | Normalized IdP permission types shared by parser and CLI | | `plugin/types.ts` | Plugin authoring types, generation hook contexts, generator config | -| `runtime/types.ts` | Runtime principal/env types (`TailorUser`, `TailorActor`, `TailorEnv`) | +| `runtime/types.ts` | Runtime principal/env types (`TailorPrincipal`, `TailorEnv`) | Rules for pure type modules (enforced by oxlint): diff --git a/.claude/rules/sdk-internals.md b/.claude/rules/sdk-internals.md index 2c004ea61..c02bf0dad 100644 --- a/.claude/rules/sdk-internals.md +++ b/.claude/rules/sdk-internals.md @@ -9,6 +9,21 @@ paths: # SDK Internals +## API Naming: `define*` vs `create*` + +Public functions in `src/configure/` follow a strict naming convention. + +| Prefix | Meaning | Output | Where used | +| --------- | --------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `define*` | Declarative resource specification | Pure data/config struct (no SDK-managed runtime methods) | `tailor.config.ts` as resource entries | +| `create*` | Instantiation of a runtime-capable unit | Object with runtime-capable methods (`trigger`, `wait`, `resolve`) or user-written `body` function | Standalone files as default/named exports, or inside workflow job bodies | + +**`define*` examples:** `defineConfig`, `defineAuth`, `defineIdp`, `defineStaticWebSite`, `defineAIGateway`, `defineSecretManager`, `definePlugins` + +**`create*` examples:** `createResolver`, `createExecutor`, `createWorkflow`, `createWorkflowJob`, `createWaitPoint`, `createWaitPoints`, `createHttpAdapter` + +Decision rule: if the result has a method that calls the platform at runtime (`.trigger()`, `.wait()`, `.resolve()`, etc.) or carries a user-written `body` function, use `create*`. If the result is purely a typed config object that tooling/deployer reads, use `define*`. + ## Module Architecture and Import Rules The SDK enforces strict module boundaries to maintain a clean architecture: diff --git a/.claude/skills/tailor b/.claude/skills/tailor new file mode 120000 index 000000000..9f908626d --- /dev/null +++ b/.claude/skills/tailor @@ -0,0 +1 @@ +../../.agents/skills/tailor \ No newline at end of file diff --git a/.claude/skills/tailor-sdk b/.claude/skills/tailor-sdk deleted file mode 120000 index 5b9c704ab..000000000 --- a/.claude/skills/tailor-sdk +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/tailor-sdk \ No newline at end of file diff --git a/.github/scripts/resolve-pending-codemod-boundaries.sh b/.github/scripts/resolve-pending-codemod-boundaries.sh new file mode 100644 index 000000000..c7241fe94 --- /dev/null +++ b/.github/scripts/resolve-pending-codemod-boundaries.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Resolves `prereleaseUntil: V2_NEXT_PENDING` codemod boundaries (see +# packages/sdk-codemod/src/registry.ts) against the version the release PR +# just bumped `@tailor-platform/sdk` to. A codemod's exact `2.0.0-next.N` +# boundary is only known once changesets/action resolves the real next +# version here, so codemods added while that version is still unknown are +# registered against the V2_NEXT_PENDING sentinel and fixed up in this step. +# +# Pushes the fixup straight to the release PR branch through the GitHub +# Contents API (like ensure-github-releases.sh) rather than a local git +# commit/push, since this token has no configured git identity or signing key. +# +# `gh pr checkout` moves the working tree onto the release PR branch, but the +# steps after this one (notably ensure-github-releases.sh) assume HEAD is +# still the commit that triggered the workflow. Restore it on every exit path +# so an unpublished release-PR commit is never mistaken for that trigger. +set -euo pipefail + +original_ref="$(git rev-parse HEAD)" +# --hard: codemod:resolve-pending and oxfmt below leave registry.ts modified, and a +# plain `git checkout` refuses to switch away from a dirty tracked file. +trap 'git reset --hard --quiet "$original_ref"' EXIT + +# changesets/action's github-api commit mode applies `changeset version`'s file +# edits (package.json bumps, CHANGELOG.md, consumed .changeset/*.md deletions) +# straight to this worktree and pushes them to the release PR branch through the +# API, but never commits them locally — leaving this checkout dirty. Without +# these, `gh pr checkout` below fails with "local changes would be overwritten" +# on every run that actually creates or updates a release PR. `clean` covers +# untracked files those edits create (a first-release package's CHANGELOG.md), +# which the release PR branch tracks and `reset --hard` leaves behind. +git reset --hard --quiet +git clean -fdq + +PR_BRANCH="$(gh pr view "$PR_NUMBER" --json headRefName -q .headRefName)" +gh pr checkout "$PR_NUMBER" +# Detach so the trap's reset only moves HEAD, not the local branch gh pr checkout made. +git checkout --quiet --detach + +pnpm codemod:resolve-pending + +registry_path="packages/sdk-codemod/src/registry.ts" +if git diff --quiet -- "$registry_path"; then + exit 0 +fi + +pnpm exec oxfmt --write "$registry_path" + +content_b64="$(base64 <"$registry_path" | tr -d '\n')" +sha="$(gh api "repos/${GITHUB_REPOSITORY}/contents/${registry_path}?ref=${PR_BRANCH}" -q .sha)" + +gh api "repos/${GITHUB_REPOSITORY}/contents/${registry_path}" \ + -X PUT \ + -f message="chore(codemod): resolve pending prerelease boundary" \ + -f content="${content_b64}" \ + -f sha="${sha}" \ + -f branch="${PR_BRANCH}" + +echo "Resolved pending codemod boundaries on ${PR_BRANCH}." diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index ade8d8dc6..908c310a0 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -20,7 +20,7 @@ jobs: if: >- github.event.pull_request.user.login != 'renovate[bot]' && - github.event.pull_request.head.ref != 'changeset-release/main' && + !startsWith(github.event.pull_request.head.ref, 'changeset-release/') && !contains(github.event.pull_request.labels.*.name, 'skip-changeset') steps: @@ -43,6 +43,7 @@ jobs: - 'packages/create-sdk/**' - 'packages/eslint-plugin-sdk/**' - 'packages/sdk-codemod/**' + - 'packages/sdk-plugin-tailordb-erd/**' - 'packages/tailor-proto/**' - name: Fail if no changeset @@ -62,7 +63,9 @@ jobs: - name: Validate changeset packages if: steps.filter.outputs.changeset == 'true' - run: pnpm changeset status --since origin/main + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: pnpm changeset status --since "origin/$BASE_REF" # `changeset status` only validates the plan; it doesn't run the JSON # edits that `changeset version` performs during the Release workflow. diff --git a/.github/workflows/check-license.yml b/.github/workflows/check-license.yml index 2de6e4dff..1f000fb77 100644 --- a/.github/workflows/check-license.yml +++ b/.github/workflows/check-license.yml @@ -2,7 +2,7 @@ name: Check Licenses on: push: - branches: [main] + branches: [main, v2] paths: - "pnpm-lock.yaml" - ".github/workflows/check-license.yml" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e49e13be..c68b06cce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: Lint / Format / Type Check on: push: - branches: [main] + branches: [main, v2] pull_request: concurrency: diff --git a/.github/workflows/cleanup-e2e-workspaces.yml b/.github/workflows/cleanup-e2e-workspaces.yml index e074c04fe..ae178e807 100644 --- a/.github/workflows/cleanup-e2e-workspaces.yml +++ b/.github/workflows/cleanup-e2e-workspaces.yml @@ -35,7 +35,7 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} @@ -43,7 +43,7 @@ jobs: - name: List e2e workspaces working-directory: packages/sdk run: | - pnpm --silent tailor-sdk workspace list --json > /tmp/workspaces.json + pnpm --silent tailor workspace list --json > /tmp/workspaces.json jq -r '.[] | select(.name | test("^(e2e-ws-|template-e2e-|sdk-ci-)")) | "\(.id)\t\(.name)"' \ /tmp/workspaces.json > /tmp/e2e-workspaces.tsv echo "e2e workspace candidates:" @@ -91,7 +91,7 @@ jobs: echo "dry $name (run $run_id status=$status)" else echo "delete $name (run $run_id status=$status)" - if pnpm --silent tailor-sdk workspace delete --workspace-id "$id" --yes; then + if pnpm --silent tailor workspace delete --workspace-id "$id" --yes; then deleted=$((deleted + 1)) else failed=$((failed + 1)) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 47a962434..aa82888aa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -73,7 +73,7 @@ jobs: - name: Login as machine user if: runner.os != 'Windows' - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} @@ -81,7 +81,7 @@ jobs: - name: Create workspace if: runner.os != 'Windows' working-directory: example - run: pnpm --silent tailor-sdk workspace create --name "sdk-ci-${RUN_ID}-${SLUG}" --region asia-northeast --json > workspace.json + run: pnpm --silent tailor workspace create --name "sdk-ci-${RUN_ID}-${SLUG}" --region asia-northeast --json > workspace.json env: RUN_ID: ${{ github.run_id }} SLUG: ${{ matrix.slug }} @@ -104,14 +104,14 @@ jobs: working-directory: example run: pnpm run deploy env: - TAILOR_PLATFORM_SDK_BUILD_ONLY: ${{ runner.os == 'Windows' && 'true' || '' }} + TAILOR_DEPLOY_BUILD_ONLY: ${{ runner.os == 'Windows' && 'true' || '' }} - name: Verify all migrations applied if: runner.os != 'Windows' working-directory: example shell: bash run: | - STATUS=$(pnpm --silent tailor-sdk tailordb migration status --namespace tailordb) + STATUS=$(pnpm --silent tailor tailordb migration status --namespace tailordb) echo "$STATUS" # Check for pending migrations @@ -143,7 +143,7 @@ jobs: - name: Destroy workspace if: always() && runner.os != 'Windows' working-directory: example - run: pnpm tailor-sdk workspace delete --workspace-id "$TAILOR_PLATFORM_WORKSPACE_ID" --yes + run: pnpm tailor workspace delete --workspace-id "$TAILOR_PLATFORM_WORKSPACE_ID" --yes deploy-result: name: deploy-result # required status check context (ruleset status_check_main) — keep equal to the job id diff --git a/.github/workflows/docs-consistency-check.yml b/.github/workflows/docs-consistency-check.yml index c565a2a4d..fcbf432e8 100644 --- a/.github/workflows/docs-consistency-check.yml +++ b/.github/workflows/docs-consistency-check.yml @@ -111,7 +111,7 @@ jobs: For changed user-facing docs, check that they describe only what an SDK *user* needs and do not leak SDK/Platform internals. Flag the following: - - **Internal implementation details**: internal API / RPC / method / class names (e.g. `TestExecScript`), transport or wire-format terms the user never types (`proto` / `protobuf` / `gRPC` / `unary RPC` / `streaming method`), internal layer structure (`parser` / `configure` / `types` modules), runtime-internal delegation mechanics, internal wrapping behavior, internal on-disk layout or sanitization algorithms (e.g. how `.tailor-sdk/` names are derived), and AST / code-injection internals. + - **Internal implementation details**: internal API / RPC / method / class names (e.g. `TestExecScript`), transport or wire-format terms the user never types (`proto` / `protobuf` / `gRPC` / `unary RPC` / `streaming method`), internal layer structure (`parser` / `configure` / `types` modules), runtime-internal delegation mechanics, internal wrapping behavior, internal on-disk layout or sanitization algorithms (e.g. how `.tailor/` names are derived), and AST / code-injection internals. - **JSDoc duplication (SSOT)**: detailed API reference — full interface/type definitions, exhaustive field tables — copied into prose. JSDoc/IDE autocompletion is the single source of truth; the docs should explain intent and link out, not mirror every field. - **Private references**: links to or mentions of private/internal repositories or internal issue trackers (e.g. a `/` link or an `/#NNN` reference). - **Wrong-audience notes**: content addressed to someone other than the reader (e.g. a "Note for Platform developers" block inside user docs). diff --git a/.github/workflows/erd-schema.yml b/.github/workflows/erd-schema.yml index b0a21a188..c6b41cb7d 100644 --- a/.github/workflows/erd-schema.yml +++ b/.github/workflows/erd-schema.yml @@ -1,15 +1,18 @@ name: ERD Schema +# pinact:skip-file on: push: - branches: [main] + branches: [main, v2] paths: - - packages/sdk/src/cli/commands/tailordb/erd/** + - packages/sdk-plugin-tailordb-erd/** + - packages/sdk/src/cli/shared/tailordb-namespaces.ts - example/** - .github/workflows/erd-schema.yml pull_request: paths: - - packages/sdk/src/cli/commands/tailordb/erd/** + - packages/sdk-plugin-tailordb-erd/** + - packages/sdk/src/cli/shared/tailordb-namespaces.ts - example/** - .github/workflows/erd-schema.yml @@ -39,9 +42,23 @@ jobs: with: persist-credentials: false + # pnpm links a package's bin only when its target file exists at install + # time, and neither a same-lockfile re-install nor `rebuild --pending` + # retries a skipped link. Stub the plugin's dist entry before install; + # the build below replaces it. + - name: Stub ERD plugin bin target + run: | + mkdir -p packages/sdk-plugin-tailordb-erd/dist + printf '#!/usr/bin/env node\n' > packages/sdk-plugin-tailordb-erd/dist/index.js + - uses: ./.github/actions/install-deps - - uses: tailor-platform/actions/erd-schema-export@a3c3032e14277e81039eb26ae1a425adb22eb532 # v1.7.0 + # install-deps only builds the SDK; `tailor tailordb erd` dispatches to + # this plugin's bin. + - name: Build ERD plugin + run: pnpm --filter @tailor-platform/sdk-plugin-tailordb-erd run build + + - uses: tailor-platform/actions/erd-schema-export@0bd69a0fc9aab3f818b636aa111995441a2c1a03 # v2 branch (unreleased) with: namespace: ${{ matrix.namespace }} package-manager: pnpm @@ -75,11 +92,25 @@ jobs: with: persist-credentials: false + # pnpm links a package's bin only when its target file exists at install + # time, and neither a same-lockfile re-install nor `rebuild --pending` + # retries a skipped link. Stub the plugin's dist entry before install; + # the build below replaces it. + - name: Stub ERD plugin bin target + run: | + mkdir -p packages/sdk-plugin-tailordb-erd/dist + printf '#!/usr/bin/env node\n' > packages/sdk-plugin-tailordb-erd/dist/index.js + # install-deps builds the SDK once, so the `tailor-sdk` CLI bin resolves. - name: Install deps uses: ./.github/actions/install-deps - - uses: tailor-platform/actions/erd-schema-preview@a3c3032e14277e81039eb26ae1a425adb22eb532 # v1.7.0 + # install-deps only builds the SDK; `tailor tailordb erd` dispatches to + # this plugin's bin. + - name: Build ERD plugin + run: pnpm --filter @tailor-platform/sdk-plugin-tailordb-erd run build + + - uses: tailor-platform/actions/erd-schema-preview@0bd69a0fc9aab3f818b636aa111995441a2c1a03 # v2 branch (unreleased) with: namespace: ${{ matrix.namespace }} package-manager: pnpm @@ -92,7 +123,8 @@ jobs: preview-artifact-name: ${{ matrix.namespace }}.html always-relevant-paths: | example/tailor.config.ts - packages/sdk/src/cli/commands/tailordb/erd/ + packages/sdk-plugin-tailordb-erd/ + packages/sdk/src/cli/shared/tailordb-namespaces.ts relevant-path-prefix: example/ github-token: ${{ github.token }} @@ -118,7 +150,7 @@ jobs: with: persist-credentials: false - - uses: tailor-platform/actions/erd-schema-comment@a3c3032e14277e81039eb26ae1a425adb22eb532 # v1.7.0 + - uses: tailor-platform/actions/erd-schema-comment@0bd69a0fc9aab3f818b636aa111995441a2c1a03 # v2 branch (unreleased) with: base-ref: ${{ github.event.pull_request.base.ref }} github-token: ${{ github.token }} diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index 35f83e231..58c522747 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -34,6 +34,7 @@ jobs: --no-progress --include-fragments --exclude 'mailto:' + --exclude 'github\.com/tailor-platform/sdk/blob/main/example/resolvers/startWorkflow\.ts' packages/sdk/docs/**/*.md fail: true diff --git a/.github/workflows/lockfile-audit.yml b/.github/workflows/lockfile-audit.yml index 61ddb1313..994fc8219 100644 --- a/.github/workflows/lockfile-audit.yml +++ b/.github/workflows/lockfile-audit.yml @@ -7,7 +7,7 @@ name: Lockfile Audit # and picomatch). This gate fails such PRs before they reach main. on: push: - branches: [main] + branches: [main, v2] paths: - pnpm-lock.yaml pull_request: diff --git a/.github/workflows/migration.yml b/.github/workflows/migration.yml index a7824a980..784066fe0 100644 --- a/.github/workflows/migration.yml +++ b/.github/workflows/migration.yml @@ -56,14 +56,14 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} - name: Create workspace working-directory: example - run: pnpm --silent tailor-sdk workspace create --name "sdk-ci-migration-${RUN_ID}" --region asia-northeast --json > workspace.json + run: pnpm --silent tailor workspace create --name "sdk-ci-migration-${RUN_ID}" --region asia-northeast --json > workspace.json env: RUN_ID: ${{ github.run_id }} TAILOR_PLATFORM_ORGANIZATION_ID: ${{ secrets.TAILOR_PLATFORM_ORGANIZATION_ID }} @@ -83,7 +83,7 @@ jobs: - name: Destroy workspace if: always() working-directory: example - run: pnpm tailor-sdk workspace delete --workspace-id "$TAILOR_PLATFORM_WORKSPACE_ID" --yes + run: pnpm tailor workspace delete --workspace-id "$TAILOR_PLATFORM_WORKSPACE_ID" --yes migration-result: name: Migration result diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 80241f5f0..1536aa51a 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -1,7 +1,7 @@ name: Publish Any Commit on: push: - branches: [main] + branches: [main, v2] pull_request: paths: - packages/** @@ -69,14 +69,14 @@ jobs: # the build and hang the job. The build itself only needs create-sdk's own deps. - name: Build create-sdk with pkg.pr.new dependencies env: - TAILOR_SDK_VERSION: ${{ steps.urls.outputs.sdk_url }} - TAILOR_SDK_ESLINT_PLUGIN_VERSION: ${{ steps.urls.outputs.eslint_plugin_sdk_url }} + TAILOR_TEMPLATE_SDK_VERSION: ${{ steps.urls.outputs.sdk_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" # zizmor: ignore[use-trusted-publishing] + run: pnpm --config.verify-deps-before-run=false exec pkg-pr-new publish --pnpm --compact --commentWithSha --packageManager=pnpm "packages/sdk" "packages/eslint-plugin-sdk" "packages/create-sdk" "packages/sdk-plugin-tailordb-erd" # zizmor: ignore[use-trusted-publishing] init-smoke: needs: [publish] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb3c38151..d9c4efb6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,7 @@ on: push: branches: - main + - v2 concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -66,6 +67,16 @@ jobs: env: GITHUB_TOKEN: ${{ steps.get_access_token.outputs.token }} + # Only fires when changesets/action created/updated a release PR (as + # opposed to publishing). See resolve-pending-codemod-boundaries.sh. + - name: Resolve pending codemod version boundaries + if: steps.changesets.outputs.pullRequestNumber + env: + GH_TOKEN: ${{ steps.get_access_token.outputs.token }} + PR_NUMBER: ${{ steps.changesets.outputs.pullRequestNumber }} + shell: bash + run: bash ./.github/scripts/resolve-pending-codemod-boundaries.sh + # Idempotent: creates a release (and its tag, via `gh release create # --target`) for every package version that doesn't have one yet, and # fails the job if creation fails — so a broken release step is caught diff --git a/.github/workflows/sdk-e2e.yml b/.github/workflows/sdk-e2e.yml index 202375438..c8e6fba05 100644 --- a/.github/workflows/sdk-e2e.yml +++ b/.github/workflows/sdk-e2e.yml @@ -55,7 +55,7 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} @@ -94,7 +94,7 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} diff --git a/.github/workflows/sdk-metrics.yml b/.github/workflows/sdk-metrics.yml index 50ff2550e..babdb977b 100644 --- a/.github/workflows/sdk-metrics.yml +++ b/.github/workflows/sdk-metrics.yml @@ -2,14 +2,13 @@ name: SDK Metrics on: pull_request: - branches: [main] paths: - packages/** - package.json - pnpm-lock.yaml - .github/workflows/sdk-metrics.yml push: - branches: [main] + branches: [main, v2] workflow_dispatch: concurrency: @@ -41,7 +40,7 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} @@ -83,7 +82,7 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} diff --git a/.github/workflows/skills-sync-check.yml b/.github/workflows/skills-sync-check.yml index 5c32d04be..6f25fc334 100644 --- a/.github/workflows/skills-sync-check.yml +++ b/.github/workflows/skills-sync-check.yml @@ -6,7 +6,7 @@ on: - "packages/sdk/agent-skills/**" - ".github/workflows/skills-sync-check.yml" push: - branches: [main] + branches: [main, v2] paths: - "packages/sdk/agent-skills/**" diff --git a/.github/workflows/template-tests.yml b/.github/workflows/template-tests.yml index f2af6d5f0..e309a2f2a 100644 --- a/.github/workflows/template-tests.yml +++ b/.github/workflows/template-tests.yml @@ -2,7 +2,7 @@ name: Template Tests on: push: - branches: [main] + branches: [main, v2] pull_request: paths: - packages/** diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3808f2e78..3a7da1877 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test on: push: - branches: [main] + branches: [main, v2] pull_request: concurrency: @@ -66,7 +66,7 @@ jobs: node-version: ${{ matrix.node }} - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} @@ -77,9 +77,37 @@ jobs: - name: Run tests run: pnpm -r run test --project 'unit*' + plugin-windows: + needs: changes + if: needs.changes.outputs.should_run == 'true' + runs-on: windows-latest + name: Plugin tests (Windows) + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install deps + uses: ./.github/actions/install-deps + + - name: Build packages + run: pnpm -r --filter '!@tailor-platform/sdk' run build + shell: bash + + # Windows-specific PATHEXT resolution and .cmd/.ps1 spawn branches are + # only exercised here; the main unit suite runs on Linux. + - name: Run plugin tests + run: pnpm --filter @tailor-platform/sdk exec vitest run --project unit-plugin + shell: bash + test-summary: name: test-summary # required status check context (ruleset status_check_main) — keep equal to the job id - needs: [changes, test] + needs: [changes, test, plugin-windows] if: >- always() && (github.event.action != 'synchronize' || github.event.sender.login != 'tailor-pr-trigger[bot]') @@ -92,6 +120,7 @@ jobs: CHANGES_RESULT: ${{ needs.changes.result }} SHOULD_RUN: ${{ needs.changes.outputs.should_run }} TEST_RESULT: ${{ needs.test.result }} + PLUGIN_WINDOWS_RESULT: ${{ needs.plugin-windows.result }} run: | # Fail closed: if the paths-filter job itself failed, should_run is # empty and must not be mistaken for "no relevant changes". @@ -107,4 +136,8 @@ jobs: echo "Test jobs failed" exit 1 fi + if [[ "$PLUGIN_WINDOWS_RESULT" != "success" ]]; then + echo "Plugin tests (Windows) failed" + exit 1 + fi echo "All test jobs succeeded" diff --git a/.gitignore b/.gitignore index 240503f96..080aea083 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,7 @@ coverage/ !.vscode/extensions.json # Tailor Platform SDK -**/.tailor-sdk/** +**/.tailor/** tailor.d.ts !packages/create-sdk/templates/*/tailor.d.ts @@ -46,7 +46,7 @@ tailor.d.ts .claude/skills/* !.claude/skills/llm-challenge !.claude/skills/docs-check -!.claude/skills/tailor-sdk +!.claude/skills/tailor !.claude/skills/e2e-test /.agents/skills/e2e-setup/ids.local.env AGENTS.local.md diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d9b88623c..8c3014a43 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -22,11 +22,12 @@ "pnpm-debug.log*", "lerna-debug.log*", "coverage/", - ".tailor-sdk", + ".tailor", "example/tests/fixtures/", "example/seed/", "packages/tailor-proto/", "packages/sdk-codemod/codemods/**/tests/", + "packages/sdk/docs/migration/v2.md", "generated/" ] } diff --git a/AGENTS.md b/AGENTS.md index b9a713b65..75eca3b51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,8 @@ When editing files matching these globs, read and follow the linked rule documen - `pnpm agent:rules:update` - Regenerate `AGENTS.md`'s path-scoped rule index and `.claude/rules/*.md` - `pnpm agent:rules:check` - Verify generated agent rule files are up to date +- `pnpm codemod:docs:update` - Regenerate `packages/sdk/docs/migration/v2.md` from the codemod registry +- `pnpm codemod:docs:check` - Verify migration docs are up to date ### CLI @@ -71,7 +73,7 @@ Refer to `example/` for working implementations of all patterns (config, models, Key files: - `example/tailor.config.ts` - Configuration with defineConfig, defineAuth, defineIdp, defineStaticWebSite, defineAIGateway, definePlugins -- `example/tailordb/*.ts` - Model definitions with `db.type()` +- `example/tailordb/*.ts` - Model definitions with `db.table()` - `example/resolvers/*.ts` - Resolver implementations with `createResolver` - `example/executors/*.ts` - Executor implementations with `createExecutor` - `example/workflows/*.ts` - Workflow implementations with `createWorkflow` / `createWorkflowJob` @@ -83,10 +85,10 @@ Key files: - `createWorkflow()` result **must** be default exported - All jobs **must** be named exports (including mainJob and triggered jobs) - Job names must be unique across the entire project -- `.trigger()` returns a `Promise>` — typically `await` it to read the value -- `defineWaitPoints(define => ({ key: define() }))` creates typed wait/resolve points +- Job `.trigger()` returns `Awaited` directly; read the value synchronously unless the job output itself is promise-like +- `createWaitPoints(define => ({ key: define() }))` creates typed wait/resolve points - Wait/resolve methods runtime-delegate to `tailor.workflow.wait/resolve` on the platform; acquire the mock with `using wf = mockWorkflow()` from `@tailor-platform/sdk/vitest` (with the `tailor-runtime` environment) and use `wf.setWaitHandler` / `wf.setResolveHandler` to mock in tests — see [testing.md](packages/sdk/docs/testing.md#jobs-that-wait-on-approval) -- Use `wps.key.wait()` for namespaced access, or `export const { key } = defineWaitPoints(...)` for destructured 2-level access +- Use `wps.key.wait()` for namespaced access, or `export const { key } = createWaitPoints(...)` for destructured 2-level access ### Executors @@ -108,7 +110,7 @@ Args include `event` (short name like `"created"`) and `rawEvent` (full event ty ### Plugins -`definePlugins()` takes plugin instances as rest arguments (see `example/tailor.config.ts`). The `kyselyTypePlugin` from `@tailor-platform/sdk/plugin/kysely-type` is required for `getDB()` in resolvers/executors/workflows. `defineGenerators()` is deprecated — use `definePlugins()` instead. +`definePlugins()` takes plugin instances as rest arguments (see `example/tailor.config.ts`). The `kyselyTypePlugin` from `@tailor-platform/sdk/plugin/kysely-type` is required for `getDB()` in resolvers/executors/workflows. ### Configuration diff --git a/docs/architecture.md b/docs/architecture.md index 5701b2cc9..a65954cb9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,7 @@ The main `packages/sdk` has four core modules: ``` src/ -├── configure/ User-facing API (defineConfig, db.type, createResolver, ...) +├── configure/ User-facing API (defineConfig, db.table, createResolver, ...) ├── parser/ Validation & transformation layer (Zod schemas → internal types) ├── cli/ CLI commands, bundling, deployment └── plugin/ Plugin system (manager + built-in plugins) diff --git a/docs/changeset.md b/docs/changeset.md index a1be7e182..d0facd955 100644 --- a/docs/changeset.md +++ b/docs/changeset.md @@ -8,6 +8,14 @@ This project uses [Changesets](https://github.com/changesets/changesets) for ver pnpm changeset ``` +## Prerelease Codemod Validation + +When validating codemod behavior before a prerelease, record the exact runner that produced the result: + +- For package-based validation, cite an exact published npm version such as `@tailor-platform/sdk-codemod@0.3.0-next.3`. +- For branch-head validation, build the package locally, run `node packages/sdk-codemod/dist/index.js ...`, and cite the `runner.gitCommit` plus `runner.localBuildCommand` from the JSON summary. +- Do not cite an older npm prerelease for behavior that only exists on the branch head. Publish a new prerelease first, or keep the validation explicitly tied to the branch commit and local build command. + ## Version Bump Levels The key question for choosing a level: **How does this change affect SDK users?** @@ -16,7 +24,7 @@ The key question for choosing a level: **How does this change affect SDK users?* SDK users must modify their code or configuration to upgrade. -- Removing or renaming a public API (`defineConfig`, `db.type`, `createResolver`, etc.) +- Removing or renaming a public API (`defineConfig`, `db.table`, `createResolver`, etc.) - Changing the signature or behavior of an existing API in an incompatible way - Changing `tailor.config.ts` format in a way that requires migration - Raising the minimum Node.js version diff --git a/docs/getting-started.md b/docs/getting-started.md index ef724ae2d..86b90f187 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,7 +4,7 @@ Guide for new SDK contributors. ## Prerequisites -- **Node.js** >= 22.14.0 +- **Node.js** >= 22.15.0 - **pnpm** (version pinned by `packageManager` in root `package.json`) - **GPG key** for commit signing (enforced by Lefthook post-commit hook) diff --git a/docs/telemetry.md b/docs/telemetry.md index 499f2c845..23c8e3e44 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -27,12 +27,12 @@ docker run -d --name jaeger-otlp \ ```bash cd example -OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 pnpm tailor-sdk deploy --dry-run +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 pnpm tailor deploy --dry-run ``` ### 3. View traces -Open http://localhost:16686, select service **tailor-sdk**, and click **Find Traces**. +Open http://localhost:16686, select service **tailor**, and click **Find Traces**. ## Span Hierarchy @@ -78,7 +78,7 @@ Individual RPC calls are also traced as `rpc.*` child spans (e.g., `rpc.CreateAp ### List spans sorted by duration ```bash -curl -s "http://localhost:16686/api/traces?service=tailor-sdk&limit=1" | jq ' +curl -s "http://localhost:16686/api/traces?service=tailor&limit=1" | jq ' .data[0].spans[] | {operationName, duration_ms: (.duration / 1000 | . * 100 | round / 100)} ' | jq -s 'sort_by(-.duration_ms)' @@ -87,7 +87,7 @@ curl -s "http://localhost:16686/api/traces?service=tailor-sdk&limit=1" | jq ' ### Show span hierarchy with parent info ```bash -curl -s "http://localhost:16686/api/traces?service=tailor-sdk&limit=1" | jq ' +curl -s "http://localhost:16686/api/traces?service=tailor&limit=1" | jq ' .data[0] as $trace | $trace.spans | map({ operationName, @@ -100,7 +100,7 @@ curl -s "http://localhost:16686/api/traces?service=tailor-sdk&limit=1" | jq ' ### Compare two traces (before/after) ```bash -curl -s "http://localhost:16686/api/traces?service=tailor-sdk&limit=2" | jq ' +curl -s "http://localhost:16686/api/traces?service=tailor&limit=2" | jq ' [.data[] | { traceID: .traceID, spans: [.spans[] diff --git a/example/.gitignore b/example/.gitignore index 045303397..45cfacc3b 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -7,7 +7,7 @@ build/ *.tsbuildinfo # Tailor Platform SDK generated files -.tailor-sdk/ +.tailor/ # Test fixtures (keep expected/ only) tests/fixtures/* diff --git a/example/.oxlintrc.json b/example/.oxlintrc.json index 9bf159a66..e59dc5750 100644 --- a/example/.oxlintrc.json +++ b/example/.oxlintrc.json @@ -8,7 +8,7 @@ "builtin": true }, "ignorePatterns": [ - ".tailor-sdk/", + ".tailor/", "generated-perf", "generated/", "migrations", @@ -61,7 +61,10 @@ "typescript/prefer-includes": "error", "typescript/prefer-nullish-coalescing": "error", "typescript/prefer-regexp-exec": "error", - "typescript/prefer-string-starts-ends-with": "error" + "typescript/prefer-string-starts-ends-with": "error", + "tailor-zod/require-object-policy-comment": "error", + "zod/prefer-loose-object": "error", + "zod/prefer-strict-object": "error" }, "overrides": [ { @@ -102,5 +105,6 @@ "import/no-cycle": "error" } } - ] + ], + "jsPlugins": ["eslint-plugin-zod", "../scripts/oxlint/tailor-zod-plugin.cjs"] } diff --git a/example/README.md b/example/README.md index 535996e36..8c3e9024e 100644 --- a/example/README.md +++ b/example/README.md @@ -16,9 +16,6 @@ This example is used for: # Generate code and types pnpm generate -# Watch mode -pnpm generate:watch - # Deploy to Tailor Platform pnpm deploy diff --git a/example/adapters/echo.ts b/example/adapters/echo.ts index c2dd2d97a..8c1548ad6 100644 --- a/example/adapters/echo.ts +++ b/example/adapters/echo.ts @@ -8,10 +8,10 @@ export default createHttpAdapter({ pathPattern: "/echo", input: { get: () => ({ - query: `query { getResult: showUserInfo { user { role } } }`, + query: `query { getResult: showUserInfo { caller { role } } }`, }), post: () => ({ - query: `query { postResult: showUserInfo { user { role } } }`, + query: `query { postResult: showUserInfo { caller { role } } }`, }), }, output: (resp) => { diff --git a/example/adapters/whoami.ts b/example/adapters/whoami.ts index 4c6d22ece..b57e794a3 100644 --- a/example/adapters/whoami.ts +++ b/example/adapters/whoami.ts @@ -35,7 +35,7 @@ export default createHttpAdapter({ get: () => ({ query: `query Whoami { showUserInfo { - user { + caller { id type role @@ -53,7 +53,7 @@ export default createHttpAdapter({ const data = resp.data as | { showUserInfo?: { - user?: Record; + caller?: Record; invoker?: Record; }; } @@ -63,7 +63,7 @@ export default createHttpAdapter({ const xml = `\n` + `` + - actorXml("user", info?.user) + + actorXml("caller", info?.caller) + actorXml("invoker", info?.invoker) + ``; return { diff --git a/example/analyticsdb/event.ts b/example/analyticsdb/event.ts index 5e9247d26..f73f518bc 100644 --- a/example/analyticsdb/event.ts +++ b/example/analyticsdb/event.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../tailordb/permissions"; export const event = db - .type("Event", { + .table("Event", { name: db.enum(["CLICK", "VIEW", "PURCHASE"]), ...db.fields.timestamps(), }) diff --git a/example/e2e/executor.test.ts b/example/e2e/executor.test.ts index 0eec3e402..658bd29bf 100644 --- a/example/e2e/executor.test.ts +++ b/example/e2e/executor.test.ts @@ -177,6 +177,7 @@ describe("dataplane", () => { email: "customer@example.com" country: "USA" postalCode: "12345" + fullAddress: "12345 USA" state: "California" } ) { diff --git a/example/e2e/globalSetup.ts b/example/e2e/globalSetup.ts index 2dfb8582f..87fbf8165 100644 --- a/example/e2e/globalSetup.ts +++ b/example/e2e/globalSetup.ts @@ -1,3 +1,4 @@ +import { setTimeout as delay } from "node:timers/promises"; import { loadAccessToken, loadWorkspaceId, @@ -6,6 +7,8 @@ import { } from "@tailor-platform/sdk/cli"; import type { TestProject } from "vitest/node"; +const machineUserTokenRetryDelays = [5_000, 10_000, 15_000, 20_000, 25_000]; + declare module "vitest" { export interface ProvidedContext { url: string; @@ -18,9 +21,7 @@ declare module "vitest" { export async function setup(project: TestProject) { const app = await show(); - const tokens = await getMachineUserToken({ - name: "manager-machine-user", - }); + const tokens = await getManagerMachineUserToken(); const workspaceId = await loadWorkspaceId(); const platformToken = await loadAccessToken(); @@ -30,3 +31,26 @@ export async function setup(project: TestProject) { project.provide("platformToken", platformToken); project.provide("appName", app.name); } + +async function getManagerMachineUserToken() { + for (let attempt = 0; ; attempt++) { + try { + return await getMachineUserToken({ + name: "manager-machine-user", + }); + } catch (error) { + const retryDelay = machineUserTokenRetryDelays[attempt]; + if (retryDelay === undefined) { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + console.warn( + `Failed to fetch manager-machine-user token: ${message}. Retrying in ${ + retryDelay / 1000 + }s.`, + ); + await delay(retryDelay); + } + } +} diff --git a/example/e2e/httpAdapter.test.ts b/example/e2e/httpAdapter.test.ts index 4271ff223..05f3890b0 100644 --- a/example/e2e/httpAdapter.test.ts +++ b/example/e2e/httpAdapter.test.ts @@ -21,7 +21,7 @@ describe("HTTP adapter routing", () => { expect(res.headers.get("content-type") ?? "").toContain("application/xml"); const body = await res.text(); expect(body).toContain(""); - expect(body).toMatch(/[\s\S]*<\/user>/); + expect(body).toMatch(/[\s\S]*<\/caller>/); }); test("POST /api/whoami fails with 404 because the adapter only declares GET", async () => { diff --git a/example/e2e/resolver.test.ts b/example/e2e/resolver.test.ts index a16448dcb..f33a4caeb 100644 --- a/example/e2e/resolver.test.ts +++ b/example/e2e/resolver.test.ts @@ -261,7 +261,7 @@ describe("dataplane", () => { const query = gql` query { showUserInfo { - user { + caller { id type workspaceId @@ -280,7 +280,7 @@ describe("dataplane", () => { expect(result.errors).toBeUndefined(); expect(result.data).toEqual({ showUserInfo: { - user: { + caller: { id: expect.any(String), type: "machine_user", workspaceId: expect.any(String), @@ -310,13 +310,15 @@ describe("dataplane", () => { const responseFields = userInfo?.response?.type?.fields ?? []; - const userField = responseFields.find((f) => f.name === "user"); - expect(userField?.description).toBe("Authenticated user"); - const userSubFields = userField?.type?.fields ?? []; - expect(userSubFields.find((f) => f.name === "id")?.description).toBe("User ID"); - expect(userSubFields.find((f) => f.name === "type")?.description).toBe("User type"); - expect(userSubFields.find((f) => f.name === "workspaceId")?.description).toBe("Workspace ID"); - expect(userSubFields.find((f) => f.name === "role")?.description).toBe("User role"); + const callerField = responseFields.find((f) => f.name === "caller"); + expect(callerField?.description).toBe("Authenticated caller"); + const callerSubFields = callerField?.type?.fields ?? []; + expect(callerSubFields.find((f) => f.name === "id")?.description).toBe("User ID"); + expect(callerSubFields.find((f) => f.name === "type")?.description).toBe("User type"); + expect(callerSubFields.find((f) => f.name === "workspaceId")?.description).toBe( + "Workspace ID", + ); + expect(callerSubFields.find((f) => f.name === "role")?.description).toBe("User role"); const invokerField = responseFields.find((f) => f.name === "invoker"); expect(invokerField?.description).toBe("Function invoker"); @@ -431,14 +433,14 @@ describe("dataplane", () => { }); }); - describe("triggerOrderProcessing", () => { - test("triggers workflow and returns workflowRunId", async () => { + describe("startOrderProcessing", () => { + test("starts workflow and returns workflowRunId", async () => { const orderId = randomUUID(); const customerId = randomUUID(); const mutation = gql` mutation { - triggerOrderProcessing( + startOrderProcessing( orderId: "${orderId}" customerId: "${customerId}" ) { @@ -450,15 +452,15 @@ describe("dataplane", () => { const result = await graphQLClient.rawRequest(mutation); expect(result.errors).toBeUndefined(); expect(result.data).toEqual({ - triggerOrderProcessing: { + startOrderProcessing: { workflowRunId: expect.any(String), - message: `Workflow triggered for order ${orderId}`, + message: `Workflow started for order ${orderId}`, }, }); // Verify workflowRunId is a valid UUID - const workflowRunId = (result.data as { triggerOrderProcessing: { workflowRunId: string } }) - .triggerOrderProcessing.workflowRunId; + const workflowRunId = (result.data as { startOrderProcessing: { workflowRunId: string } }) + .startOrderProcessing.workflowRunId; expect(workflowRunId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, ); @@ -473,33 +475,33 @@ describe("dataplane", () => { pipelineResolverView: PipelineResolverView.FULL, }); - const triggerResolver = pipelineResolvers.find((e) => e.name === "triggerOrderProcessing"); - expect(triggerResolver).toBeDefined(); - expect(triggerResolver).toMatchObject({ - name: "triggerOrderProcessing", - description: "Trigger the order processing workflow", + const startResolver = pipelineResolvers.find((e) => e.name === "startOrderProcessing"); + expect(startResolver).toBeDefined(); + expect(startResolver).toMatchObject({ + name: "startOrderProcessing", + description: "Start the order processing workflow", operationType: "mutation", }); // Verify inputs - const inputNames = triggerResolver?.inputs?.map((i) => i.name) ?? []; + const inputNames = startResolver?.inputs?.map((i) => i.name) ?? []; expect(inputNames).toContain("orderId"); expect(inputNames).toContain("customerId"); // Verify orderId input - const orderIdInput = triggerResolver?.inputs?.find((i) => i.name === "orderId"); + const orderIdInput = startResolver?.inputs?.find((i) => i.name === "orderId"); expect(orderIdInput?.description).toBe("Order ID to process"); expect(orderIdInput?.type?.kind).toBe("ScalarType"); expect(orderIdInput?.type?.name).toBe("String"); // Verify customerId input - const customerIdInput = triggerResolver?.inputs?.find((i) => i.name === "customerId"); + const customerIdInput = startResolver?.inputs?.find((i) => i.name === "customerId"); expect(customerIdInput?.description).toBe("Customer ID for the order"); expect(customerIdInput?.type?.kind).toBe("ScalarType"); - expect(customerIdInput?.type?.name).toBe("String"); + expect(customerIdInput?.type?.name).toBe("ID"); // Verify response - const responseFields = triggerResolver?.response?.type?.fields ?? []; + const responseFields = startResolver?.response?.type?.fields ?? []; const workflowRunIdField = responseFields.find((f) => f.name === "workflowRunId"); expect(workflowRunIdField).toBeDefined(); const messageField = responseFields.find((f) => f.name === "message"); diff --git a/example/e2e/tailordb.test.ts b/example/e2e/tailordb.test.ts index 91499ca17..3846ddfe6 100644 --- a/example/e2e/tailordb.test.ts +++ b/example/e2e/tailordb.test.ts @@ -47,13 +47,11 @@ describe("controlplane", () => { type: "datetime", required: true, array: false, - hooks: expect.any(Object), }, updatedAt: { type: "datetime", - required: false, + required: true, array: false, - hooks: expect.any(Object), }, }, indexes: { @@ -236,6 +234,7 @@ describe("dataplane", () => { email: "customer-${randomUUID()}@example.com" country: "USA" postalCode: "12345" + fullAddress: "12345 USA" state: "California" } ) { @@ -419,6 +418,7 @@ describe("dataplane", () => { email: "customer@example.com" country: "USA" postalCode: "12345" + fullAddress: "12345 USA" state: "California" } ) { @@ -497,8 +497,8 @@ describe("dataplane", () => { createUser: { id: string; name: string; - createdAt?: string; - updatedAt?: string; + createdAt: string; + updatedAt: string; }; } const createResult = await graphQLClient.rawRequest(create); @@ -508,7 +508,7 @@ describe("dataplane", () => { id: expect.any(String), name: "alice", createdAt: expect.any(String), - updatedAt: null, + updatedAt: expect.any(String), }, }); const userId = createResult.data.createUser.id; @@ -546,6 +546,7 @@ describe("dataplane", () => { postalCode: "12345" address: "123 Main St" city: "Los Angeles" + fullAddress: "12345 123 Main St Los Angeles" state: "California" } ) { @@ -577,6 +578,7 @@ describe("dataplane", () => { email: "bob@example.com" country: "USA" postalCode: "12345" + fullAddress: "12345 USA" state: "California" } ) { @@ -611,6 +613,153 @@ describe("dataplane", () => { }); }); + describe("productBundle", async () => { + test("type-level hook computes label from input fields", async () => { + const query = gql` + mutation { + createProductBundle( + input: { + name: "Summer Sale" + items: [ + { productName: "Widget", qty: 1, unitPrice: 10.0 } + { productName: "Gadget", qty: 2, unitPrice: 25.0 } + ] + } + ) { + id + name + label + items { + productName + qty + unitPrice + } + } + } + `; + const result = await graphQLClient.rawRequest(query); + expect(result.errors).toBeUndefined(); + expect(result.data).toEqual({ + createProductBundle: { + id: expect.any(String), + name: "Summer Sale", + label: "Summer Sale Bundle", + items: [ + { productName: "Widget", qty: 1, unitPrice: 10.0 }, + { productName: "Gadget", qty: 2, unitPrice: 25.0 }, + ], + }, + }); + }); + + test("type-level hook recomputes label on update", async () => { + const create = gql` + mutation { + createProductBundle( + input: { name: "Original", items: [{ productName: "Item", qty: 1, unitPrice: 5.0 }] } + ) { + id + label + } + } + `; + interface Data { + createProductBundle: { id: string; label: string }; + } + const createResult = await graphQLClient.rawRequest(create); + expect(createResult.errors).toBeUndefined(); + expect(createResult.data.createProductBundle.label).toBe("Original Bundle"); + const id = createResult.data.createProductBundle.id; + + const update = gql` + mutation { + updateProductBundle(id: "${id}", input: { name: "Renamed" }) { + id + label + } + } + `; + const updateResult = await graphQLClient.rawRequest(update); + expect(updateResult.errors).toBeUndefined(); + expect(updateResult.data).toEqual({ + updateProductBundle: { + id, + label: "Renamed Bundle", + }, + }); + }); + + test("type-level hook falls back to oldRecord when name is not in input", async () => { + const create = gql` + mutation { + createProductBundle( + input: { name: "Stable", items: [{ productName: "Item", qty: 1, unitPrice: 5.0 }] } + ) { + id + label + } + } + `; + interface Data { + createProductBundle: { id: string; label: string }; + } + const createResult = await graphQLClient.rawRequest(create); + expect(createResult.errors).toBeUndefined(); + expect(createResult.data.createProductBundle.label).toBe("Stable Bundle"); + const id = createResult.data.createProductBundle.id; + + const update = gql` + mutation { + updateProductBundle( + id: "${id}" + input: { items: [{ productName: "Item", qty: 3, unitPrice: 5.0 }] } + ) { + id + name + label + } + } + `; + const updateResult = await graphQLClient.rawRequest(update); + expect(updateResult.errors).toBeUndefined(); + expect(updateResult.data).toEqual({ + updateProductBundle: { + id, + name: "Stable", + label: "Stable Bundle", + }, + }); + }); + + test("inner field validate rejects invalid qty", async () => { + const query = gql` + mutation { + createProductBundle( + input: { name: "Bad qty", items: [{ productName: "Widget", qty: 0, unitPrice: 10.0 }] } + ) { + id + } + } + `; + const result = await graphQLClient.rawRequest(query); + expect(result.errors).toBeDefined(); + expect(result.errors![0].message).toMatch("qty must be positive"); + }); + + test("type-level validate rejects empty items", async () => { + const query = gql` + mutation { + createProductBundle(input: { name: "Empty", items: [] }) { + id + } + } + `; + const result = await graphQLClient.rawRequest(query); + expect(result.errors).toBeDefined(); + expect(result.errors![0].message).toMatch("At least one item is required"); + }); + }); + describe("file", async () => { test("file type field returns", async () => { const query = gql` diff --git a/example/e2e/utils.ts b/example/e2e/utils.ts index 67d83ba8f..544a6fa91 100644 --- a/example/e2e/utils.ts +++ b/example/e2e/utils.ts @@ -5,7 +5,7 @@ import { GraphQLClient } from "graphql-request"; import { inject } from "vitest"; export function createOperatorClient() { - const baseUrl = process.env.PLATFORM_URL ?? "https://api.tailor.tech"; + const baseUrl = process.env.TAILOR_PLATFORM_URL ?? "https://api.tailor.tech"; const workspaceId = inject("workspaceId"); const platformToken = inject("platformToken"); @@ -70,7 +70,7 @@ function userAgentInterceptor(): Interceptor { return await next(req); } - req.header.set("User-Agent", "tailor-sdk-ci"); + req.header.set("User-Agent", "tailor-ci"); return await next(req); }; } diff --git a/example/generated/files.ts b/example/generated/files.ts index 0389b9acd..54ebb3a5a 100644 --- a/example/generated/files.ts +++ b/example/generated/files.ts @@ -1,9 +1,9 @@ -import * as file from "@tailor-platform/sdk/runtime/file"; +import { file } from "@tailor-platform/sdk/runtime/file"; import type { FileUploadOptions, FileUploadResponse, FileMetadata, - FileStreamIterator, + FileDownloadStreamResponse, } from "@tailor-platform/sdk/runtime/file"; export interface TypeWithFiles { @@ -58,10 +58,10 @@ export async function getFileMetadata( return await file.getMetadata(namespaces[type], type, field, recordId); } -export async function openFileDownloadStream( +export async function downloadFileStream( type: T, field: TypeWithFiles[T]["fields"], recordId: string, -): Promise { - return await file.openDownloadStream(namespaces[type], type, field, recordId); +): Promise { + return await file.downloadStream(namespaces[type], type, field, recordId); } diff --git a/example/generated/tailordb.ts b/example/generated/tailordb.ts index db9ea0e47..668154306 100644 --- a/example/generated/tailordb.ts +++ b/example/generated/tailordb.ts @@ -24,10 +24,10 @@ export interface Namespace { postalCode: string; address: string | null; city: string | null; - fullAddress: Generated; + fullAddress: string; state: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Invoice: { @@ -38,7 +38,7 @@ export interface Namespace { sequentialId: Serial; status: "draft" | "sent" | "paid" | "cancelled" | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } NestedProfile: { @@ -57,7 +57,20 @@ export interface Namespace { }>; archived: boolean | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; + } + + ProductBundle: { + id: Generated; + name: string; + label: string | null; + items: { + productName: string; + qty: number; + unitPrice: number; + }[]; + createdAt: Generated; + updatedAt: Generated; } PurchaseOrder: { @@ -73,7 +86,7 @@ export interface Namespace { type: "text" | "image"; }[]; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } SalesOrder: { @@ -86,7 +99,7 @@ export interface Namespace { cancelReason: string | null; canceledAt: Timestamp | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } SalesOrderCreated: { @@ -115,7 +128,7 @@ export interface Namespace { state: "Alabama" | "Alaska"; city: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -126,7 +139,7 @@ export interface Namespace { department: string | null; role: "MANAGER" | "STAFF"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } UserLog: { @@ -134,7 +147,7 @@ export interface Namespace { userID: string; message: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } UserSetting: { @@ -142,7 +155,7 @@ export interface Namespace { language: "jp" | "en"; userID: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } }, "analyticsdb": { @@ -150,7 +163,7 @@ export interface Namespace { id: Generated; name: "CLICK" | "VIEW" | "PURCHASE"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/example/migrations/0002/diff.json b/example/migrations/0002/diff.json new file mode 100644 index 000000000..e3abafca4 --- /dev/null +++ b/example/migrations/0002/diff.json @@ -0,0 +1,644 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-06-17T04:37:34.736Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "name", + "before": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value.length>5)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "Name must be longer than 5 characters" + } + ] + }, + "after": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value.length>5)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "Name must be longer than 5 characters" + } + ] + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "city", + "before": { + "type": "string", + "required": false, + "validate": [ + { + "script": { + "expr": "(({value})=>value?value.length>1:true)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "failed by `({value})=>value?value.length>1:true`" + }, + { + "script": { + "expr": "(({value})=>value?value.length<100:true)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "failed by `({value})=>value?value.length<100:true`" + } + ] + }, + "after": { + "type": "string", + "required": false, + "validate": [ + { + "script": { + "expr": "(({value})=>value?value.length>1:true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value?value.length>1:true`" + }, + { + "script": { + "expr": "(({value})=>value?value.length<100:true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value?value.length<100:true`" + } + ] + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "fullAddress", + "before": { + "type": "string", + "required": true, + "hooks": { + "create": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "update": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "string", + "required": true, + "hooks": { + "create": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "attachedFiles", + "before": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value>0)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "failed by `({value})=>value>0`" + } + ] + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + }, + "after": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value>0)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value>0`" + } + ] + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "hasWarnings": false, + "warnings": [], + "requiresMigrationScript": false +} diff --git a/example/migrations/0003/db.ts b/example/migrations/0003/db.ts new file mode 100644 index 000000000..3dc32b41c --- /dev/null +++ b/example/migrations/0003/db.ts @@ -0,0 +1,132 @@ +/** + * Auto-generated Kysely types for migration script. + * These types reflect the database schema state at this migration point. + * + * DO NOT EDIT - This file is auto-generated by the migration system. + */ + +import { + type ColumnType, + type Transaction as KyselyTransaction, +} from "@tailor-platform/sdk/kysely"; +import type { Env } from "@tailor-platform/sdk"; + +type Timestamp = ColumnType; +type Generated = + T extends ColumnType + ? ColumnType + : ColumnType; + +interface Database { + Customer: { + id: Generated; + name: string; + email: string; + phone: string | null; + country: string; + postalCode: string; + address: string | null; + city: string | null; + fullAddress: string; + state: string; + createdAt: Timestamp; + updatedAt: ColumnType; + }; + Invoice: { + id: Generated; + invoiceNumber: string; + salesOrderID: string; + amount: number | null; + sequentialId: number; + status: "draft" | "sent" | "paid" | "cancelled" | null; + createdAt: Timestamp; + updatedAt: ColumnType; + }; + NestedProfile: { + id: Generated; + userInfo: string; + metadata: string; + archived: boolean | null; + createdAt: Timestamp; + updatedAt: ColumnType; + }; + PurchaseOrder: { + id: Generated; + supplierID: string; + totalPrice: number; + discount: number | null; + status: string; + attachedFiles: string[]; + createdAt: Timestamp; + updatedAt: ColumnType; + }; + SalesOrder: { + id: Generated; + customerID: string; + approvedByUserIDs: string[] | null; + totalPrice: number | null; + discount: number | null; + status: string | null; + cancelReason: string | null; + canceledAt: Timestamp | null; + createdAt: Timestamp; + updatedAt: ColumnType; + }; + SalesOrderCreated: { + id: Generated; + salesOrderID: string; + customerID: string; + totalPrice: number | null; + status: string | null; + }; + Selfie: { + id: Generated; + name: string; + parentID: string | null; + dependId: string | null; + }; + Supplier: { + id: Generated; + name: string; + phone: string; + fax: string | null; + email: string | null; + postalCode: string; + country: string; + state: "Alabama" | "Alaska"; + city: string; + createdAt: Timestamp; + updatedAt: ColumnType; + }; + User: { + id: Generated; + name: string; + email: string; + status: string | null; + department: string | null; + role: "MANAGER" | "STAFF"; + createdAt: Timestamp; + updatedAt: ColumnType; + }; + UserLog: { + id: Generated; + userID: string; + message: string; + createdAt: Timestamp; + updatedAt: ColumnType; + }; + UserSetting: { + id: Generated; + language: "jp" | "en"; + userID: string; + createdAt: Timestamp; + updatedAt: ColumnType; + }; +} + +export type Transaction = KyselyTransaction; + +/** Context passed as the second argument to the migration's `main` function. */ +export type MigrationContext = { + env: keyof Env extends never ? Record : Env; +}; diff --git a/example/migrations/0003/diff.json b/example/migrations/0003/diff.json new file mode 100644 index 000000000..b97ecf496 --- /dev/null +++ b/example/migrations/0003/diff.json @@ -0,0 +1,311 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-06-17T15:41:21.506Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + } + ], + "hasBreakingChanges": true, + "breakingChanges": [ + { + "typeName": "Customer", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "Invoice", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "NestedProfile", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "PurchaseOrder", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "SalesOrder", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "Supplier", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "User", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "UserLog", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "UserSetting", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + } + ], + "hasWarnings": false, + "warnings": [], + "requiresMigrationScript": true, + "description": "set updatedAt on create" +} diff --git a/example/migrations/0003/migrate.ts b/example/migrations/0003/migrate.ts new file mode 100644 index 000000000..8b6518888 --- /dev/null +++ b/example/migrations/0003/migrate.ts @@ -0,0 +1,70 @@ +/** + * Migration script for tailordb + * + * This script runs between the Pre-migration and Post-migration phases of + * 'tailor deploy'. Use it to transform existing data so that the schema + * change can complete safely (for breaking changes, this is hard-required; + * for warning-tier changes it is optional). Edit this file to implement + * your data migration logic. + * + * The transaction is managed by the deploy command. + * If any operation fails, all changes will be rolled back. + */ + +import type { Transaction } from "./db"; + +export async function main(trx: Transaction): Promise { + await trx + .updateTable("Customer") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("Invoice") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("NestedProfile") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("PurchaseOrder") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("SalesOrder") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("Supplier") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("User") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("UserLog") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("UserSetting") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); +} diff --git a/example/migrations/0004/diff.json b/example/migrations/0004/diff.json new file mode 100644 index 000000000..a38bf0c38 --- /dev/null +++ b/example/migrations/0004/diff.json @@ -0,0 +1,194 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-06-24T04:44:47.663Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "name", + "before": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value.length>5)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "Name must be longer than 5 characters" + } + ] + }, + "after": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({ value })=>value.length > 5)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "Name must be longer than 5 characters" + } + ] + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "city", + "before": { + "type": "string", + "required": false, + "validate": [ + { + "script": { + "expr": "(({value})=>value?value.length>1:true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value?value.length>1:true`" + }, + { + "script": { + "expr": "(({value})=>value?value.length<100:true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value?value.length<100:true`" + } + ] + }, + "after": { + "type": "string", + "required": false, + "validate": [ + { + "script": { + "expr": "(({ value })=>value ? value.length > 1 : true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({ value })=>value ? value.length > 1 : true`" + }, + { + "script": { + "expr": "(({ value })=>value ? value.length < 100 : true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({ value })=>value ? value.length < 100 : true`" + } + ] + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "fullAddress", + "before": { + "type": "string", + "required": true, + "hooks": { + "create": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "string", + "required": true, + "hooks": { + "create": { + "expr": "(({ data })=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(({ data })=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "attachedFiles", + "before": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value>0)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value>0`" + } + ] + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + }, + "after": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({ value })=>value > 0)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({ value })=>value > 0`" + } + ] + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "hasWarnings": false, + "warnings": [], + "requiresMigrationScript": false +} diff --git a/example/migrations/0005/diff.json b/example/migrations/0005/diff.json new file mode 100644 index 000000000..1bd383023 --- /dev/null +++ b/example/migrations/0005/diff.json @@ -0,0 +1,822 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-07-13T16:35:52.223Z", + "changes": [ + { + "kind": "type_added", + "typeName": "ProductBundle", + "after": { + "name": "ProductBundle", + "pluralForm": "ProductBundles", + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "label": { + "type": "string", + "required": false + }, + "items": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "productName": { + "type": "string", + "required": true + }, + "qty": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({ value })=>value <= 0 ? \"qty must be positive\" : undefined)({ value: _value })" + }, + "errorMessage": "" + } + ] + }, + "unitPrice": { + "type": "float", + "required": true + } + } + }, + "createdAt": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "default": "now" + }, + "updatedAt": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "update": { + "expr": "(({ input, now }) => input ?? now)({ input: _value, oldValue: _oldValue, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + } + }, + "default": "now" + } + }, + "description": "Product bundle with nested array hooks", + "settings": {}, + "typeHookExpr": { + "create": "(({ input })=>({\n label: `${input.name} Bundle`\n }))({ input: _input, oldRecord: _oldRecord, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })", + "update": "(({ input, oldRecord })=>({\n label: `${input.name ?? oldRecord.name} Bundle`\n }))({ input: _input, oldRecord: _oldRecord, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + }, + "typeValidateExpr": "(({ newRecord }, issues)=>{\n if (newRecord.items && newRecord.items.length === 0) {\n issues(\"items\", \"At least one item is required\");\n }\n})({ newRecord: _newRecord, oldRecord: _oldRecord, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) }, __issues)", + "permissions": { + "record": { + "create": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "read": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + }, + { + "conditions": [ + [ + { + "user": "_loggedIn" + }, + "eq", + true + ] + ], + "permit": "allow" + } + ], + "update": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "delete": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ] + }, + "gql": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "actions": ["create", "read", "update", "delete", "aggregate", "bulkUpsert"], + "permit": "allow" + }, + { + "conditions": [ + [ + { + "user": "_loggedIn" + }, + "eq", + true + ] + ], + "actions": ["read"], + "permit": "allow" + } + ] + } + } + }, + { + "kind": "type_scripts_modified", + "typeName": "Customer", + "reason": "type-level scripts changed", + "before": {}, + "after": { + "typeHookExpr": { + "create": "(({ input })=>({\n fullAddress: `${input.postalCode} ${input.address} ${input.city}`\n }))({ input: _input, oldRecord: _oldRecord, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })", + "update": "(({ input, oldRecord })=>({\n fullAddress: `${input.postalCode ?? oldRecord.postalCode} ${input.address ?? oldRecord.address} ${input.city ?? oldRecord.city}`\n }))({ input: _input, oldRecord: _oldRecord, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + }, + "typeValidateExpr": "(({ newRecord }, issues)=>{\n if (newRecord.country === \"JP\" && !newRecord.postalCode) {\n issues(\"postalCode\", \"Postal code is required for Japan\");\n }\n})({ newRecord: _newRecord, oldRecord: _oldRecord, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) }, __issues)" + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "name", + "before": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({ value })=>value.length > 5)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "Name must be longer than 5 characters" + } + ] + }, + "after": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({ value })=>value.length <= 5 ? \"Name must be longer than 5 characters\" : undefined)({ value: _value })" + }, + "errorMessage": "" + } + ] + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "city", + "before": { + "type": "string", + "required": false, + "validate": [ + { + "script": { + "expr": "(({ value })=>value ? value.length > 1 : true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({ value })=>value ? value.length > 1 : true`" + }, + { + "script": { + "expr": "(({ value })=>value ? value.length < 100 : true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({ value })=>value ? value.length < 100 : true`" + } + ] + }, + "after": { + "type": "string", + "required": false, + "validate": [ + { + "script": { + "expr": "(({ value })=>value && value.length <= 1 ? \"City must be longer than 1 character\" : undefined)({ value: _value })" + }, + "errorMessage": "" + }, + { + "script": { + "expr": "(({ value })=>value && value.length >= 100 ? \"City must be shorter than 100 characters\" : undefined)({ value: _value })" + }, + "errorMessage": "" + } + ] + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "fullAddress", + "before": { + "type": "string", + "required": true, + "hooks": { + "create": { + "expr": "(({ data })=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(({ data })=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "string", + "required": true + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "update": { + "expr": "(({ input, now }) => input ?? now)({ input: _value, oldValue: _oldValue, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + } + }, + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "update": { + "expr": "(({ input, now }) => input ?? now)({ input: _value, oldValue: _oldValue, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + } + }, + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "update": { + "expr": "(({ input, now }) => input ?? now)({ input: _value, oldValue: _oldValue, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + } + }, + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "attachedFiles", + "before": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({ value })=>value > 0)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({ value })=>value > 0`" + } + ] + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + }, + "after": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({ value })=>value <= 0 ? \"Size must be positive\" : undefined)({ value: _value })" + }, + "errorMessage": "" + } + ] + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "update": { + "expr": "(({ input, now }) => input ?? now)({ input: _value, oldValue: _oldValue, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + } + }, + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "update": { + "expr": "(({ input, now }) => input ?? now)({ input: _value, oldValue: _oldValue, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + } + }, + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "update": { + "expr": "(({ input, now }) => input ?? now)({ input: _value, oldValue: _oldValue, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + } + }, + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "update": { + "expr": "(({ input, now }) => input ?? now)({ input: _value, oldValue: _oldValue, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + } + }, + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "update": { + "expr": "(({ input, now }) => input ?? now)({ input: _value, oldValue: _oldValue, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + } + }, + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "update": { + "expr": "(({ input, now }) => input ?? now)({ input: _value, oldValue: _oldValue, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + } + }, + "default": "now" + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "hasWarnings": false, + "warnings": [], + "requiresMigrationScript": false +} diff --git a/example/migrations/analyticsdb/0000/schema.json b/example/migrations/analyticsdb/0000/schema.json new file mode 100644 index 000000000..4b5cfd05f --- /dev/null +++ b/example/migrations/analyticsdb/0000/schema.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "namespace": "analyticsdb", + "createdAt": "2026-06-17T14:01:24.799Z", + "types": { + "Event": { + "name": "Event", + "pluralForm": "Events", + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "CLICK" + }, + { + "value": "VIEW" + }, + { + "value": "PURCHASE" + } + ] + }, + "createdAt": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "updatedAt": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + "settings": {}, + "files": { + "screenshot": "screenshot image" + } + } + } +} diff --git a/example/migrations/analyticsdb/0001/db.ts b/example/migrations/analyticsdb/0001/db.ts new file mode 100644 index 000000000..e4f20afd9 --- /dev/null +++ b/example/migrations/analyticsdb/0001/db.ts @@ -0,0 +1,34 @@ +/** + * Auto-generated Kysely types for migration script. + * These types reflect the database schema state at this migration point. + * + * DO NOT EDIT - This file is auto-generated by the migration system. + */ + +import { + type ColumnType, + type Transaction as KyselyTransaction, +} from "@tailor-platform/sdk/kysely"; +import type { Env } from "@tailor-platform/sdk"; + +type Timestamp = ColumnType; +type Generated = + T extends ColumnType + ? ColumnType + : ColumnType; + +interface Database { + Event: { + id: Generated; + name: "CLICK" | "VIEW" | "PURCHASE"; + createdAt: Timestamp; + updatedAt: ColumnType; + }; +} + +export type Transaction = KyselyTransaction; + +/** Context passed as the second argument to the migration's `main` function. */ +export type MigrationContext = { + env: keyof Env extends never ? Record : Env; +}; diff --git a/example/migrations/analyticsdb/0001/diff.json b/example/migrations/analyticsdb/0001/diff.json new file mode 100644 index 000000000..dfd283801 --- /dev/null +++ b/example/migrations/analyticsdb/0001/diff.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "namespace": "analyticsdb", + "createdAt": "2026-06-17T14:01:59.003Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Event", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + } + ], + "hasBreakingChanges": true, + "breakingChanges": [ + { + "typeName": "Event", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + } + ], + "hasWarnings": false, + "warnings": [], + "requiresMigrationScript": true +} diff --git a/example/migrations/analyticsdb/0001/migrate.ts b/example/migrations/analyticsdb/0001/migrate.ts new file mode 100644 index 000000000..43f459e54 --- /dev/null +++ b/example/migrations/analyticsdb/0001/migrate.ts @@ -0,0 +1,22 @@ +/** + * Migration script for analyticsdb + * + * This script runs between the Pre-migration and Post-migration phases of + * 'tailor deploy'. Use it to transform existing data so that the schema + * change can complete safely (for breaking changes, this is hard-required; + * for warning-tier changes it is optional). Edit this file to implement + * your data migration logic. + * + * The transaction is managed by the deploy command. + * If any operation fails, all changes will be rolled back. + */ + +import type { Transaction } from "./db"; + +export async function main(trx: Transaction): Promise { + await trx + .updateTable("Event") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); +} diff --git a/example/migrations/analyticsdb/0002/diff.json b/example/migrations/analyticsdb/0002/diff.json new file mode 100644 index 000000000..ab030b323 --- /dev/null +++ b/example/migrations/analyticsdb/0002/diff.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "namespace": "analyticsdb", + "createdAt": "2026-07-13T13:43:18.552Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Event", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "default": "now" + } + }, + { + "kind": "field_modified", + "typeName": "Event", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "update": { + "expr": "(({ input, now }) => input ?? now)({ input: _value, oldValue: _oldValue, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user), now: _now })" + } + }, + "default": "now" + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "hasWarnings": false, + "warnings": [], + "requiresMigrationScript": false +} diff --git a/example/migrations/analyticsdb/0003/diff.json b/example/migrations/analyticsdb/0003/diff.json new file mode 100644 index 000000000..86e27e95c --- /dev/null +++ b/example/migrations/analyticsdb/0003/diff.json @@ -0,0 +1,118 @@ +{ + "version": 1, + "namespace": "analyticsdb", + "createdAt": "2026-07-16T00:07:50.902Z", + "changes": [ + { + "kind": "permission_modified", + "typeName": "Event", + "reason": "record permission and GQL permission changed", + "before": {}, + "after": { + "recordPermission": { + "create": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "read": [ + { + "conditions": [ + [ + { + "user": "_loggedIn" + }, + "eq", + true + ] + ], + "permit": "allow" + }, + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "update": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "delete": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ] + }, + "gqlPermission": [ + { + "conditions": [ + [ + { + "user": "_loggedIn" + }, + "eq", + true + ] + ], + "actions": ["read"], + "permit": "allow" + }, + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "actions": ["create", "read", "update", "delete", "aggregate", "bulkUpsert"], + "permit": "allow" + } + ] + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "hasWarnings": false, + "warnings": [], + "requiresMigrationScript": false +} diff --git a/example/package.json b/example/package.json index 6dce7e858..af87063c7 100644 --- a/example/package.json +++ b/example/package.json @@ -6,9 +6,8 @@ "license": "MIT", "type": "module", "scripts": { - "generate": "tailor-sdk generate -c tailor.config.ts", - "generate:watch": "tailor-sdk generate -c tailor.config.ts --watch", - "deploy": "tailor-sdk deploy -c tailor.config.ts", + "generate": "tailor generate -c tailor.config.ts", + "deploy": "tailor deploy -c tailor.config.ts", "test": "pnpm test:generator", "test:all": "pnpm test:generator:prepare && vitest", "test:generator": "pnpm test:generator:prepare && vitest --project generator", @@ -17,7 +16,7 @@ "test:e2e": "vitest --project e2e", "migration:e2e": "tsx tests/scripts/migration_e2e.ts", "seed:validate": "node ./seed/exec.mjs validate", - "seed:truncate": "tailor-sdk tailordb truncate -a", + "seed:truncate": "tailor tailordb truncate -a", "seed": "node ./seed/exec.mjs", "analyze:bundle": "tsx tests/scripts/analyze_minified_size.ts", "lint": "oxlint --type-aware .", @@ -35,8 +34,10 @@ "@bufbuild/protobuf": "2.12.1", "@connectrpc/connect": "2.1.2", "@connectrpc/connect-node": "2.1.2", + "@tailor-platform/sdk-plugin-tailordb-erd": "workspace:^", "@types/node": "24.13.3", "@typescript/native-preview": "7.0.0-dev.20260707.2", + "eslint-plugin-zod": "4.7.0", "graphql": "17.0.2", "graphql-request": "7.4.0", "multiline-ts": "4.0.1", @@ -49,6 +50,6 @@ "vitest": "4.1.10" }, "engines": { - "node": ">= 22.14.0" + "node": ">= 22.15.0" } } diff --git a/example/resolvers/add.ts b/example/resolvers/add.ts index 2656931ca..f3a0460b7 100644 --- a/example/resolvers/add.ts +++ b/example/resolvers/add.ts @@ -1,9 +1,9 @@ import { createResolver, t } from "@tailor-platform/sdk"; -const validators: [(a: { value: number }) => boolean, string][] = [ - [({ value }) => value >= 0, "Value must be non-negative"], - [({ value }) => value < 10, "Value must be less than 10"], -]; +const validators = [ + ({ value }: { value: number }) => (value >= 0 ? undefined : "Value must be non-negative"), + ({ value }: { value: number }) => (value < 10 ? undefined : "Value must be less than 10"), +] as const; export default createResolver({ name: "add", description: "Addition operation", diff --git a/example/resolvers/passThrough.ts b/example/resolvers/passThrough.ts index adeaa78e7..6fa561461 100644 --- a/example/resolvers/passThrough.ts +++ b/example/resolvers/passThrough.ts @@ -2,8 +2,8 @@ import { createResolver, t } from "@tailor-platform/sdk"; import { nestedProfile } from "../tailordb/nested"; const inputFields = { - ...nestedProfile.pickFields(["id", "createdAt"], { optional: true }), - ...nestedProfile.omitFields(["id", "createdAt"]), + ...nestedProfile.pickFields(["id", "createdAt", "updatedAt"], { optional: true }), + ...nestedProfile.omitFields(["id", "createdAt", "updatedAt"]), }; export default createResolver({ operation: "query", @@ -13,10 +13,14 @@ export default createResolver({ id: t.uuid({ optional: true }), input: t.object(inputFields), }, - body: ({ input }) => ({ - ...input.input, - id: input.id ?? crypto.randomUUID(), - createdAt: new Date(), - }), + body: ({ input }) => { + const now = new Date(); + return { + ...input.input, + id: input.id ?? crypto.randomUUID(), + createdAt: input.input.createdAt ?? now, + updatedAt: input.input.updatedAt ?? now, + }; + }, output: nestedProfile.fields, }); diff --git a/example/resolvers/triggerWorkflow.ts b/example/resolvers/startWorkflow.ts similarity index 54% rename from example/resolvers/triggerWorkflow.ts rename to example/resolvers/startWorkflow.ts index 5c6c3297b..2a58e6030 100644 --- a/example/resolvers/triggerWorkflow.ts +++ b/example/resolvers/startWorkflow.ts @@ -2,26 +2,26 @@ import { createResolver, t } from "@tailor-platform/sdk"; import orderProcessingWorkflow from "../workflows/order-processing"; export default createResolver({ - name: "triggerOrderProcessing", - description: "Trigger the order processing workflow", + name: "startOrderProcessing", + description: "Start the order processing workflow", operation: "mutation", input: { orderId: t.string().description("Order ID to process"), - customerId: t.string().description("Customer ID for the order"), + customerId: t.uuid().description("Customer ID for the order"), }, body: async ({ input }) => { - // Trigger the workflow with authInvoker (machine user name is type-narrowed via tailor.d.ts) - const workflowRunId = await orderProcessingWorkflow.trigger( + // Start the workflow with invoker (machine user name is type-narrowed via tailor.d.ts) + const workflowRunId = await orderProcessingWorkflow.start( { orderId: input.orderId, customerId: input.customerId, }, - { authInvoker: "manager-machine-user" }, + { invoker: "manager-machine-user" }, ); return { workflowRunId, - message: `Workflow triggered for order ${input.orderId}`, + message: `Workflow started for order ${input.orderId}`, }; }, output: t.object({ diff --git a/example/resolvers/stepChain.ts b/example/resolvers/stepChain.ts index 123cdc864..a5cd9dd04 100644 --- a/example/resolvers/stepChain.ts +++ b/example/resolvers/stepChain.ts @@ -14,17 +14,15 @@ export default createResolver({ first: t .string() .description("User's first name") - .validate([ - ({ value }) => value.length >= 2, - "First name must be at least 2 characters", - ]), + .validate(({ value }) => + value.length >= 2 ? undefined : "First name must be at least 2 characters", + ), last: t .string() .description("User's last name") - .validate([ - ({ value }) => value.length >= 2, - "Last name must be at least 2 characters", - ]), + .validate(({ value }) => + value.length >= 2 ? undefined : "Last name must be at least 2 characters", + ), }) .description("User's full name"), activatedAt: t.datetime({ optional: true }).description("User activation timestamp"), diff --git a/example/resolvers/userInfo.ts b/example/resolvers/userInfo.ts index 4d4943dc6..abcccba0b 100644 --- a/example/resolvers/userInfo.ts +++ b/example/resolvers/userInfo.ts @@ -6,11 +6,11 @@ export default createResolver({ operation: "query", body: (context) => { return { - user: { - id: context.user.id, - type: context.user.type, - workspaceId: context.user.workspaceId, - role: context.user.attributes?.role ?? "MANAGER", + caller: { + id: context.caller?.id ?? "", + type: context.caller?.type ?? "", + workspaceId: context.caller?.workspaceId ?? "", + role: context.caller?.attributes.role ?? "MANAGER", }, invoker: { id: context.invoker!.id, @@ -22,14 +22,14 @@ export default createResolver({ }, output: t .object({ - user: t + caller: t .object({ id: t.string().description("User ID"), type: t.string().description("User type"), workspaceId: t.string().description("Workspace ID"), role: t.enum(["MANAGER", "STAFF"]).description("User role"), }) - .description("Authenticated user"), + .description("Authenticated caller"), invoker: t .object({ id: t.string().description("Invoker ID"), @@ -40,5 +40,5 @@ export default createResolver({ .description("Function invoker"), }) .description("User and invoker information"), - authInvoker: "manager-machine-user", + invoker: "manager-machine-user", }); diff --git a/example/seed/data/Customer.jsonl b/example/seed/data/Customer.jsonl index bbb6aacc5..393308674 100644 --- a/example/seed/data/Customer.jsonl +++ b/example/seed/data/Customer.jsonl @@ -1,5 +1,5 @@ -{"id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","name":"Acme Corporation","email":"contact@acme.com","phone":"03-1234-5678","country":"Japan","postalCode":"100-0001","address":"Chiyoda-ku","city":"Tokyo","state":"Tokyo"} -{"id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","name":"Global Tech Inc","email":"info@globaltech.com","phone":"03-9876-5432","country":"Japan","postalCode":"150-0002","address":"Shibuya-ku","city":"Tokyo","state":"Tokyo"} -{"id":"cccccccc-cccc-cccc-cccc-cccccccccccc","name":"Enterprise Solutions Ltd","email":"sales@enterprise.com","phone":"06-1111-2222","country":"Japan","postalCode":"530-0001","address":"Kita-ku","city":"Osaka","state":"Osaka"} -{"id":"dddddddd-dddd-dddd-dddd-dddddddddddd","name":"Digital Services Co","email":"hello@digital.com","country":"Japan","postalCode":"810-0001","address":"Chuo-ku","city":"Fukuoka","state":"Fukuoka"} -{"id":"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee","name":"Innovation Partners","email":"contact@innovation.com","phone":"011-3333-4444","country":"Japan","postalCode":"060-0001","address":"Chuo-ku","city":"Sapporo","state":"Hokkaido"} +{"id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","name":"Acme Corporation","email":"contact@acme.com","phone":"03-1234-5678","country":"Japan","postalCode":"100-0001","address":"Chiyoda-ku","city":"Tokyo","fullAddress":"100-0001 Chiyoda-ku Tokyo","state":"Tokyo"} +{"id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","name":"Global Tech Inc","email":"info@globaltech.com","phone":"03-9876-5432","country":"Japan","postalCode":"150-0002","address":"Shibuya-ku","city":"Tokyo","fullAddress":"150-0002 Shibuya-ku Tokyo","state":"Tokyo"} +{"id":"cccccccc-cccc-cccc-cccc-cccccccccccc","name":"Enterprise Solutions Ltd","email":"sales@enterprise.com","phone":"06-1111-2222","country":"Japan","postalCode":"530-0001","address":"Kita-ku","city":"Osaka","fullAddress":"530-0001 Kita-ku Osaka","state":"Osaka"} +{"id":"dddddddd-dddd-dddd-dddd-dddddddddddd","name":"Digital Services Co","email":"hello@digital.com","country":"Japan","postalCode":"810-0001","address":"Chuo-ku","city":"Fukuoka","fullAddress":"810-0001 Chuo-ku Fukuoka","state":"Fukuoka"} +{"id":"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee","name":"Innovation Partners","email":"contact@innovation.com","phone":"011-3333-4444","country":"Japan","postalCode":"060-0001","address":"Chuo-ku","city":"Sapporo","fullAddress":"060-0001 Chuo-ku Sapporo","state":"Hokkaido"} diff --git a/example/seed/data/Customer.schema.ts b/example/seed/data/Customer.schema.ts index 756c2f29e..7a7fa636e 100644 --- a/example/seed/data/Customer.schema.ts +++ b/example/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"], { optional: true }), - ...customer.omitFields(["id","fullAddress","createdAt"]), + ...customer.pickFields(["id"], { optional: true }), + ...customer.omitFields(["id"]), }); const hook = createTailorDBHook(customer); diff --git a/example/seed/data/Event.schema.ts b/example/seed/data/Event.schema.ts index 0bc3d8691..4ecc2c631 100644 --- a/example/seed/data/Event.schema.ts +++ b/example/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"], { optional: true }), - ...event.omitFields(["id","createdAt"]), + ...event.pickFields(["id"], { optional: true }), + ...event.omitFields(["id"]), }); const hook = createTailorDBHook(event); diff --git a/example/seed/data/Invoice.schema.ts b/example/seed/data/Invoice.schema.ts index b25da906f..830a0b6ab 100644 --- a/example/seed/data/Invoice.schema.ts +++ b/example/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"], { optional: true }), - ...invoice.omitFields(["id","createdAt","invoiceNumber","sequentialId"]), + ...invoice.pickFields(["id"], { optional: true }), + ...invoice.omitFields(["id","invoiceNumber","sequentialId"]), }); const hook = createTailorDBHook(invoice); diff --git a/example/seed/data/NestedProfile.schema.ts b/example/seed/data/NestedProfile.schema.ts index 2c52ea377..3dfb27864 100644 --- a/example/seed/data/NestedProfile.schema.ts +++ b/example/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"], { optional: true }), - ...nestedProfile.omitFields(["id","createdAt"]), + ...nestedProfile.pickFields(["id"], { optional: true }), + ...nestedProfile.omitFields(["id"]), }); const hook = createTailorDBHook(nestedProfile); diff --git a/example/tests/migration-fixtures/app/migrations/.gitkeep b/example/seed/data/ProductBundle.jsonl similarity index 100% rename from example/tests/migration-fixtures/app/migrations/.gitkeep rename to example/seed/data/ProductBundle.jsonl diff --git a/example/seed/data/ProductBundle.schema.ts b/example/seed/data/ProductBundle.schema.ts new file mode 100644 index 000000000..88bef6380 --- /dev/null +++ b/example/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/seed/data/PurchaseOrder.schema.ts b/example/seed/data/PurchaseOrder.schema.ts index 3a26ef3a3..45c7bbd82 100644 --- a/example/seed/data/PurchaseOrder.schema.ts +++ b/example/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"], { optional: true }), - ...purchaseOrder.omitFields(["id","createdAt"]), + ...purchaseOrder.pickFields(["id"], { optional: true }), + ...purchaseOrder.omitFields(["id"]), }); const hook = createTailorDBHook(purchaseOrder); diff --git a/example/seed/data/SalesOrder.schema.ts b/example/seed/data/SalesOrder.schema.ts index 3f2533204..df41e5934 100644 --- a/example/seed/data/SalesOrder.schema.ts +++ b/example/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"], { optional: true }), - ...salesOrder.omitFields(["id","createdAt"]), + ...salesOrder.pickFields(["id"], { optional: true }), + ...salesOrder.omitFields(["id"]), }); const hook = createTailorDBHook(salesOrder); diff --git a/example/seed/data/Supplier.schema.ts b/example/seed/data/Supplier.schema.ts index bac16337c..06a5da1db 100644 --- a/example/seed/data/Supplier.schema.ts +++ b/example/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"], { optional: true }), - ...supplier.omitFields(["id","createdAt"]), + ...supplier.pickFields(["id"], { optional: true }), + ...supplier.omitFields(["id"]), }); const hook = createTailorDBHook(supplier); diff --git a/example/seed/data/User.schema.ts b/example/seed/data/User.schema.ts index 6c5a84d86..e0de10bca 100644 --- a/example/seed/data/User.schema.ts +++ b/example/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"], { optional: true }), - ...user.omitFields(["id","createdAt"]), + ...user.pickFields(["id"], { optional: true }), + ...user.omitFields(["id"]), }); const hook = createTailorDBHook(user); diff --git a/example/seed/data/UserLog.schema.ts b/example/seed/data/UserLog.schema.ts index 32dfc98fa..c173ffae0 100644 --- a/example/seed/data/UserLog.schema.ts +++ b/example/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"], { optional: true }), - ...userLog.omitFields(["id","createdAt"]), + ...userLog.pickFields(["id"], { optional: true }), + ...userLog.omitFields(["id"]), }); const hook = createTailorDBHook(userLog); diff --git a/example/seed/data/UserSetting.schema.ts b/example/seed/data/UserSetting.schema.ts index 553d42c9e..9c4ab3200 100644 --- a/example/seed/data/UserSetting.schema.ts +++ b/example/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"], { optional: true }), - ...userSetting.omitFields(["id","createdAt"]), + ...userSetting.pickFields(["id"], { optional: true }), + ...userSetting.omitFields(["id"]), }); const hook = createTailorDBHook(userSetting); diff --git a/example/seed/exec.mjs b/example/seed/exec.mjs index 203be8544..0e065fa92 100644 --- a/example/seed/exec.mjs +++ b/example/seed/exec.mjs @@ -149,6 +149,7 @@ const namespaceEntities = { "Customer", "Invoice", "NestedProfile", + "ProductBundle", "PurchaseOrder", "SalesOrder", "SalesOrderCreated", @@ -167,6 +168,7 @@ const namespaceDeps = { "Customer": [], "Invoice": ["SalesOrder"], "NestedProfile": [], + "ProductBundle": [], "PurchaseOrder": ["Supplier"], "SalesOrder": ["Customer", "User"], "SalesOrderCreated": [], @@ -307,7 +309,6 @@ const operatorClient = await initOperatorClient(accessToken); workspaceId, name: "truncate-idp-user.ts", code: idpTruncateCode, - arg: JSON.stringify({}), invoker: { namespace: authNamespace, machineUserName, @@ -501,7 +502,7 @@ const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) workspaceId, name: `seed-${namespace}.ts`, code: bundled.bundledCode, - arg: JSON.stringify({ data: chunk.data, order: chunk.order, selfRefTypes }), + arg: { data: chunk.data, order: chunk.order, selfRefTypes }, invoker: { namespace: authNamespace, machineUserName, @@ -596,7 +597,7 @@ const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) workspaceId, name: "seed-idp-user.ts", code: idpSeedCode, - arg: JSON.stringify({ users: rows }), + arg: { users: rows }, invoker: { namespace: authNamespace, machineUserName, diff --git a/example/tailor.config.ts b/example/tailor.config.ts index 7292f5c3c..4b066caae 100644 --- a/example/tailor.config.ts +++ b/example/tailor.config.ts @@ -113,7 +113,7 @@ export default defineConfig({ // SDK-managed app id — do not edit, except when copying this config to a separate app. id: "d0a3398a-f79c-4c2e-be1e-b81469bb0a43", name: "my-app", - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", env: { foo: 1, bar: "hello", @@ -130,7 +130,12 @@ export default defineConfig({ directory: "./migrations", }, }, - analyticsdb: { files: ["./analyticsdb/*.ts"] }, + analyticsdb: { + files: ["./analyticsdb/*.ts"], + migration: { + directory: "./migrations/analyticsdb", + }, + }, }, resolver: { "my-resolver": { files: ["./resolvers/*.ts"] }, diff --git a/example/tailordb/customer.ts b/example/tailordb/customer.ts index efde41cb1..81b8ea3b5 100644 --- a/example/tailordb/customer.ts +++ b/example/tailordb/customer.ts @@ -2,29 +2,39 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "./permissions"; export const customer = db - .type("Customer", "Customer information", { - name: db.string(), + .table("Customer", "Customer information", { + name: db + .string() + .validate(({ value }) => + value.length <= 5 ? "Name must be longer than 5 characters" : undefined, + ), email: db.string(), phone: db.string({ optional: true }), country: db.string(), postalCode: db.string(), address: db.string({ optional: true }), city: db.string({ optional: true }).validate( - ({ value }) => (value ? value.length > 1 : true), - ({ value }) => (value ? value.length < 100 : true), + ({ value }) => + value && value.length <= 1 ? "City must be longer than 1 character" : undefined, + ({ value }) => + value && value.length >= 100 ? "City must be shorter than 100 characters" : undefined, ), fullAddress: db.string(), state: db.string(), ...db.fields.timestamps(), }) .hooks({ - fullAddress: { - create: ({ data }) => `${data.postalCode} ${data.address} ${data.city}`, - update: ({ data }) => `${data.postalCode} ${data.address} ${data.city}`, - }, + create: ({ input }) => ({ + fullAddress: `${input.postalCode} ${input.address} ${input.city}`, + }), + update: ({ input, oldRecord }) => ({ + fullAddress: `${input.postalCode ?? oldRecord.postalCode} ${input.address ?? oldRecord.address} ${input.city ?? oldRecord.city}`, + }), }) - .validate({ - name: [({ value }) => value.length > 5, "Name must be longer than 5 characters"], + .validate(({ newRecord }, issues) => { + if (newRecord.country === "JP" && !newRecord.postalCode) { + issues("postalCode", "Postal code is required for Japan"); + } }) .permission(defaultPermission) .gqlPermission(defaultGqlPermission); diff --git a/example/tailordb/file.ts b/example/tailordb/file.ts index 38726367c..3a553bd55 100644 --- a/example/tailordb/file.ts +++ b/example/tailordb/file.ts @@ -4,7 +4,7 @@ export const attachedFiles = db.object( { id: db.uuid(), name: db.string(), - size: db.int().validate(({ value }) => value > 0), + size: db.int().validate(({ value }) => (value <= 0 ? "Size must be positive" : undefined)), type: db.enum(["text", "image"]), }, { array: true }, diff --git a/example/tailordb/invoice.ts b/example/tailordb/invoice.ts index 04963b2cc..d019b0d7e 100644 --- a/example/tailordb/invoice.ts +++ b/example/tailordb/invoice.ts @@ -3,7 +3,7 @@ import { defaultGqlPermission, defaultPermission } from "./permissions"; import { salesOrder } from "./salesOrder"; export const invoice = db - .type("Invoice", { + .table("Invoice", { invoiceNumber: db.string().serial({ start: 1000, format: "INV-%05d", diff --git a/example/tailordb/nested.ts b/example/tailordb/nested.ts index b3fa800ba..c39106055 100644 --- a/example/tailordb/nested.ts +++ b/example/tailordb/nested.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "./permissions"; export const nestedProfile = db - .type("NestedProfile", "Nested Profile Type", { + .table("NestedProfile", "Nested Profile Type", { userInfo: db .object({ name: db.string().description("User's full name"), diff --git a/example/tailordb/productBundle.ts b/example/tailordb/productBundle.ts new file mode 100644 index 000000000..f614c2e74 --- /dev/null +++ b/example/tailordb/productBundle.ts @@ -0,0 +1,32 @@ +import { db } from "@tailor-platform/sdk"; +import { defaultGqlPermission, defaultPermission } from "./permissions"; + +export const productBundle = db + .table("ProductBundle", "Product bundle with nested array hooks", { + name: db.string(), + label: db.string({ optional: true }), + items: db.object( + { + productName: db.string(), + qty: db.int().validate(({ value }) => (value <= 0 ? "qty must be positive" : undefined)), + unitPrice: db.float(), + }, + { array: true }, + ), + ...db.fields.timestamps(), + }) + .hooks({ + create: ({ input }) => ({ + label: `${input.name} Bundle`, + }), + update: ({ input, oldRecord }) => ({ + label: `${input.name ?? oldRecord.name} Bundle`, + }), + }) + .validate(({ newRecord }, issues) => { + if (newRecord.items && newRecord.items.length === 0) { + issues("items", "At least one item is required"); + } + }) + .permission(defaultPermission) + .gqlPermission(defaultGqlPermission); diff --git a/example/tailordb/purchaseOrder.ts b/example/tailordb/purchaseOrder.ts index 72d636943..faae74ebf 100644 --- a/example/tailordb/purchaseOrder.ts +++ b/example/tailordb/purchaseOrder.ts @@ -4,7 +4,7 @@ import { defaultGqlPermission, defaultPermission } from "./permissions"; import { supplier } from "./supplier"; export const purchaseOrder = db - .type(["PurchaseOrder", "PurchaseOrderList"], { + .table(["PurchaseOrder", "PurchaseOrderList"], { supplierID: db.uuid().relation({ type: "n-1", toward: { type: supplier }, diff --git a/example/tailordb/salesOrder.ts b/example/tailordb/salesOrder.ts index 2f79c8c87..d5ab96284 100644 --- a/example/tailordb/salesOrder.ts +++ b/example/tailordb/salesOrder.ts @@ -4,7 +4,7 @@ import { defaultPermission, defaultGqlPermission } from "./permissions"; import { user } from "./user"; export const salesOrder = db - .type(["SalesOrder", "SalesOrderList"], { + .table(["SalesOrder", "SalesOrderList"], { customerID: db.uuid().relation({ type: "n-1", toward: { type: customer }, @@ -32,7 +32,7 @@ export const salesOrder = db .gqlPermission(defaultGqlPermission); export const salesOrderCreated = db - .type(["SalesOrderCreated", "SalesOrderCreatedList"], { + .table(["SalesOrderCreated", "SalesOrderCreatedList"], { salesOrderID: db.uuid(), customerID: db.uuid(), totalPrice: db.int({ optional: true }), diff --git a/example/tailordb/selfie.ts b/example/tailordb/selfie.ts index 088007b73..bf4247c05 100644 --- a/example/tailordb/selfie.ts +++ b/example/tailordb/selfie.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "./permissions"; export const selfie = db - .type("Selfie", { + .table("Selfie", { name: db.string(), parentID: db.uuid({ optional: true }).relation({ type: "n-1", diff --git a/example/tailordb/supplier.ts b/example/tailordb/supplier.ts index 6706c23c5..85730c370 100644 --- a/example/tailordb/supplier.ts +++ b/example/tailordb/supplier.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "./permissions"; export const supplier = db - .type("Supplier", { + .table("Supplier", { name: db.string(), phone: db.string(), fax: db.string({ optional: true }), diff --git a/example/tailordb/user.ts b/example/tailordb/user.ts index 1bc50a0f9..fcb053bbd 100644 --- a/example/tailordb/user.ts +++ b/example/tailordb/user.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "./permissions"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string().unique(), status: db.string({ optional: true }), diff --git a/example/tailordb/userLog.ts b/example/tailordb/userLog.ts index 6da54b6b6..bd1f7560b 100644 --- a/example/tailordb/userLog.ts +++ b/example/tailordb/userLog.ts @@ -3,7 +3,7 @@ import { defaultGqlPermission, defaultPermission } from "./permissions"; import { user } from "./user"; export const userLog = db - .type("UserLog", { + .table("UserLog", { userID: db.uuid().relation({ type: "n-1", toward: { type: user } }), message: db.string(), ...db.fields.timestamps(), diff --git a/example/tailordb/userSetting.ts b/example/tailordb/userSetting.ts index 0f354e607..7d75c86a4 100644 --- a/example/tailordb/userSetting.ts +++ b/example/tailordb/userSetting.ts @@ -3,7 +3,7 @@ import { defaultGqlPermission, defaultPermission } from "./permissions"; import { user } from "./user"; export const userSetting = db - .type("UserSetting", { + .table("UserSetting", { language: db.enum(["jp", "en"]), userID: db.uuid().relation({ type: "1-1", diff --git a/example/tests/bundled_execution.test.ts b/example/tests/bundled_execution.test.ts index 32c1ba2aa..4273aa922 100644 --- a/example/tests/bundled_execution.test.ts +++ b/example/tests/bundled_execution.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import "@tailor-platform/sdk/runtime/globals"; import { mockTailordb, mockWorkflow } from "@tailor-platform/sdk/vitest"; import { format as formatDate } from "date-fns"; import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; @@ -47,7 +48,7 @@ describe("bundled execution tests", () => { "resolvers/add.js": 5459 + sizeBuffer, "resolvers/showUserInfo.js": 5999 + sizeBuffer, "resolvers/stepChain.js": 182391 + sizeBuffer, - "resolvers/triggerOrderProcessing.js": 5692 + sizeBuffer, + "resolvers/startOrderProcessing.js": 5692 + sizeBuffer, // workflow-jobs: Kysely jobs (~158KB), date-fns jobs (~20KB), simple jobs (<2KB) "workflow-jobs/check-inventory.js": 19967 + sizeBuffer, "workflow-jobs/fetch-customer.js": 157770 + sizeBuffer, @@ -76,7 +77,7 @@ describe("bundled execution tests", () => { expect(result).toEqual(10); }); - test("resolvers/showUserInfo.js returns user and invoker information", async () => { + test("resolvers/showUserInfo.js returns caller and invoker information", async () => { using _invokerSpy = vi.spyOn(globalThis.tailor.context, "getInvoker").mockReturnValue({ id: "f1e2d3c4-b5a6-4798-89a0-1b2c3d4e5f60", type: "machine_user", @@ -87,7 +88,7 @@ describe("bundled execution tests", () => { const main = await importActualMain("resolvers/showUserInfo.js"); const payload = { - user: { + caller: { id: "57485cfe-fc74-4d46-8660-f0e95d1fbf98", type: "user", workspaceId: "b39bdd61-d442-4a4e-8599-33a78a4e19ab", @@ -96,7 +97,7 @@ describe("bundled execution tests", () => { }; const result = await main(payload); expect(result).toEqual({ - user: { + caller: { id: "57485cfe-fc74-4d46-8660-f0e95d1fbf98", type: "user", workspaceId: "b39bdd61-d442-4a4e-8599-33a78a4e19ab", @@ -210,7 +211,7 @@ describe("bundled execution tests", () => { processedAt: "2025-01-01 12:00:00", }); - expect(wf.triggeredJobs).toEqual([ + expect(wf.startedJobs).toEqual([ { jobName: "fetch-customer", args: { customerId: "customer-456" } }, { jobName: "send-notification", @@ -254,7 +255,7 @@ describe("bundled execution tests", () => { }); }); - test("workflow-jobs/validate-order.js triggers check-inventory job", async () => { + test("workflow-jobs/validate-order.js starts check-inventory job", async () => { using wf = mockWorkflow(); wf.setJobHandler((jobName) => { if (jobName === "check-inventory") { @@ -271,7 +272,7 @@ describe("bundled execution tests", () => { paymentResult: null, }); - expect(wf.triggeredJobs).toEqual([ + expect(wf.startedJobs).toEqual([ { jobName: "check-inventory", args: undefined }, { jobName: "process-payment", args: undefined }, ]); diff --git a/example/tests/fixtures/expected/db.ts b/example/tests/fixtures/expected/db.ts index db9ea0e47..e769dbf3a 100644 --- a/example/tests/fixtures/expected/db.ts +++ b/example/tests/fixtures/expected/db.ts @@ -27,7 +27,7 @@ export interface Namespace { fullAddress: Generated; state: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Invoice: { @@ -38,7 +38,7 @@ export interface Namespace { sequentialId: Serial; status: "draft" | "sent" | "paid" | "cancelled" | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } NestedProfile: { @@ -57,7 +57,7 @@ export interface Namespace { }>; archived: boolean | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } PurchaseOrder: { @@ -73,7 +73,7 @@ export interface Namespace { type: "text" | "image"; }[]; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } SalesOrder: { @@ -86,7 +86,7 @@ export interface Namespace { cancelReason: string | null; canceledAt: Timestamp | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } SalesOrderCreated: { @@ -115,7 +115,7 @@ export interface Namespace { state: "Alabama" | "Alaska"; city: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -126,7 +126,7 @@ export interface Namespace { department: string | null; role: "MANAGER" | "STAFF"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } UserLog: { @@ -134,7 +134,7 @@ export interface Namespace { userID: string; message: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } UserSetting: { @@ -142,7 +142,7 @@ export interface Namespace { language: "jp" | "en"; userID: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } }, "analyticsdb": { @@ -150,7 +150,7 @@ export interface Namespace { id: Generated; name: "CLICK" | "VIEW" | "PURCHASE"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/example/tests/fixtures/expected/files.ts b/example/tests/fixtures/expected/files.ts index 0389b9acd..54ebb3a5a 100644 --- a/example/tests/fixtures/expected/files.ts +++ b/example/tests/fixtures/expected/files.ts @@ -1,9 +1,9 @@ -import * as file from "@tailor-platform/sdk/runtime/file"; +import { file } from "@tailor-platform/sdk/runtime/file"; import type { FileUploadOptions, FileUploadResponse, FileMetadata, - FileStreamIterator, + FileDownloadStreamResponse, } from "@tailor-platform/sdk/runtime/file"; export interface TypeWithFiles { @@ -58,10 +58,10 @@ export async function getFileMetadata( return await file.getMetadata(namespaces[type], type, field, recordId); } -export async function openFileDownloadStream( +export async function downloadFileStream( type: T, field: TypeWithFiles[T]["fields"], recordId: string, -): Promise { - return await file.openDownloadStream(namespaces[type], type, field, recordId); +): Promise { + return await file.downloadStream(namespaces[type], type, field, recordId); } diff --git a/example/tests/fixtures/expected/seed/data/Customer.schema.ts b/example/tests/fixtures/expected/seed/data/Customer.schema.ts index 145333a65..11ecb751c 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"], { optional: true }), - ...customer.omitFields(["id","fullAddress","createdAt"]), + ...customer.pickFields(["id","fullAddress","createdAt","updatedAt"], { optional: true }), + ...customer.omitFields(["id","fullAddress","createdAt","updatedAt"]), }); 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 2c9f19fc4..50a3f4d02 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"], { optional: true }), - ...event.omitFields(["id","createdAt"]), + ...event.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...event.omitFields(["id","createdAt","updatedAt"]), }); 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 020f3b52e..a2a7cfc49 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"], { optional: true }), - ...invoice.omitFields(["id","createdAt","invoiceNumber","sequentialId"]), + ...invoice.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...invoice.omitFields(["id","createdAt","updatedAt","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 38dc3b4e1..2424ec58c 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"], { optional: true }), - ...nestedProfile.omitFields(["id","createdAt"]), + ...nestedProfile.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...nestedProfile.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(nestedProfile); diff --git a/example/tests/fixtures/expected/seed/data/PurchaseOrder.schema.ts b/example/tests/fixtures/expected/seed/data/PurchaseOrder.schema.ts index 4e4f269a4..c73327132 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"], { optional: true }), - ...purchaseOrder.omitFields(["id","createdAt"]), + ...purchaseOrder.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...purchaseOrder.omitFields(["id","createdAt","updatedAt"]), }); 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 db0543332..37a210130 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"], { optional: true }), - ...salesOrder.omitFields(["id","createdAt"]), + ...salesOrder.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...salesOrder.omitFields(["id","createdAt","updatedAt"]), }); 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 3fb6af855..db4627803 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"], { optional: true }), - ...supplier.omitFields(["id","createdAt"]), + ...supplier.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...supplier.omitFields(["id","createdAt","updatedAt"]), }); 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 4c9d24707..21342b67d 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"], { optional: true }), - ...user.omitFields(["id","createdAt"]), + ...user.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...user.omitFields(["id","createdAt","updatedAt"]), }); 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 fd3b3c52b..556c81e96 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"], { optional: true }), - ...userLog.omitFields(["id","createdAt"]), + ...userLog.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...userLog.omitFields(["id","createdAt","updatedAt"]), }); 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 d5f2fb052..87fec2df1 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"], { optional: true }), - ...userSetting.omitFields(["id","createdAt"]), + ...userSetting.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...userSetting.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(userSetting); diff --git a/example/tests/fixtures/expected/seed/exec.mjs b/example/tests/fixtures/expected/seed/exec.mjs index 0f47f91d9..b69257e4f 100644 --- a/example/tests/fixtures/expected/seed/exec.mjs +++ b/example/tests/fixtures/expected/seed/exec.mjs @@ -307,7 +307,6 @@ const operatorClient = await initOperatorClient(accessToken); workspaceId, name: "truncate-idp-user.ts", code: idpTruncateCode, - arg: JSON.stringify({}), invoker: { namespace: authNamespace, machineUserName, @@ -501,7 +500,7 @@ const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) workspaceId, name: `seed-${namespace}.ts`, code: bundled.bundledCode, - arg: JSON.stringify({ data: chunk.data, order: chunk.order, selfRefTypes }), + arg: { data: chunk.data, order: chunk.order, selfRefTypes }, invoker: { namespace: authNamespace, machineUserName, @@ -596,7 +595,7 @@ const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) workspaceId, name: "seed-idp-user.ts", code: idpSeedCode, - arg: JSON.stringify({ users: rows }), + arg: { users: rows }, invoker: { namespace: authNamespace, machineUserName, diff --git a/example/tests/invoker.types.ts b/example/tests/invoker.types.ts new file mode 100644 index 000000000..07b31daad --- /dev/null +++ b/example/tests/invoker.types.ts @@ -0,0 +1,17 @@ +// Type-level checks against the generated `tailor.d.ts`. Once `tailor +// generate` augments `MachineUserNameRegistry`, `MachineUserName` narrows to the +// registered machine user union for both SDK entries — `@tailor-platform/sdk` +// (resolver `invoker`) and `@tailor-platform/sdk/cli` (workflow-start +// `invoker`), which share the single `@tailor-platform/sdk` augmentation. +import type { MachineUserName as SdkMachineUserName } from "@tailor-platform/sdk"; +import type { MachineUserName as CliMachineUserName } from "@tailor-platform/sdk/cli"; + +const sdkInvoker: SdkMachineUserName = "manager-machine-user"; +const cliInvoker: CliMachineUserName = "manager-machine-user"; + +// @ts-expect-error - unknown machine user names are rejected once tailor.d.ts is generated +const unknownSdkInvoker: SdkMachineUserName = "unknown-machine-user"; +// @ts-expect-error - unknown machine user names are rejected once tailor.d.ts is generated +const unknownCliInvoker: CliMachineUserName = "unknown-machine-user"; + +export const invokerTypeChecks = [sdkInvoker, cliInvoker, unknownSdkInvoker, unknownCliInvoker]; diff --git a/example/tests/migration-fixtures/app/migrations/0000/schema.json b/example/tests/migration-fixtures/app/migrations/0000/schema.json new file mode 100644 index 000000000..7dbfe28c2 --- /dev/null +++ b/example/tests/migration-fixtures/app/migrations/0000/schema.json @@ -0,0 +1,382 @@ +{ + "version": 1, + "namespace": "migrationdb", + "createdAt": "2026-06-24T04:43:34.470Z", + "types": { + "SalesOrder": { + "name": "SalesOrder", + "pluralForm": "SalesOrders", + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "customerID": { + "type": "uuid", + "required": true + }, + "status": { + "type": "string", + "required": false + }, + "totalPrice": { + "type": "integer", + "required": false + }, + "createdAt": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "updatedAt": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + "settings": {}, + "permissions": { + "record": { + "create": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "read": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "update": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "delete": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ] + }, + "gql": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "actions": ["create", "read", "update", "delete", "aggregate", "bulkUpsert"], + "permit": "allow" + } + ] + } + }, + "Supplier": { + "name": "Supplier", + "pluralForm": "Suppliers", + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": false + }, + "country": { + "type": "string", + "required": false + }, + "phone": { + "type": "string", + "required": true + }, + "state": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "Alabama" + }, + { + "value": "Alaska" + } + ] + }, + "city": { + "type": "string", + "required": true + }, + "createdAt": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "updatedAt": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + "settings": {}, + "permissions": { + "record": { + "create": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "read": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "update": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "delete": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ] + }, + "gql": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "actions": ["create", "read", "update", "delete", "aggregate", "bulkUpsert"], + "permit": "allow" + } + ] + } + }, + "User": { + "name": "User", + "pluralForm": "Users", + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "email": { + "type": "string", + "required": true + }, + "createdAt": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "updatedAt": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + "settings": {}, + "permissions": { + "record": { + "create": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "read": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "update": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "delete": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ] + }, + "gql": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "actions": ["create", "read", "update", "delete", "aggregate", "bulkUpsert"], + "permit": "allow" + } + ] + } + } + } +} diff --git a/example/tests/migration-fixtures/app/tailordb/.gitkeep b/example/tests/migration-fixtures/app/tailordb/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/example/tests/migration-fixtures/app/tailordb/salesOrder.ts b/example/tests/migration-fixtures/app/tailordb/salesOrder.ts new file mode 100644 index 000000000..6bf2082d4 --- /dev/null +++ b/example/tests/migration-fixtures/app/tailordb/salesOrder.ts @@ -0,0 +1,12 @@ +import { db } from "@tailor-platform/sdk"; +import { defaultGqlPermission, defaultPermission } from "../permissions"; + +export const salesOrder = db + .table("SalesOrder", { + customerID: db.uuid(), + status: db.string({ optional: true }), + totalPrice: db.int({ optional: true }), + ...db.fields.timestamps(), + }) + .permission(defaultPermission) + .gqlPermission(defaultGqlPermission); diff --git a/example/tests/migration-fixtures/app/tailordb/supplier.ts b/example/tests/migration-fixtures/app/tailordb/supplier.ts new file mode 100644 index 000000000..d629a21ef --- /dev/null +++ b/example/tests/migration-fixtures/app/tailordb/supplier.ts @@ -0,0 +1,14 @@ +import { db } from "@tailor-platform/sdk"; +import { defaultGqlPermission, defaultPermission } from "../permissions"; + +export const supplier = db + .table("Supplier", { + name: db.string({ optional: true }), + country: db.string({ optional: true }), + phone: db.string(), + state: db.enum(["Alabama", "Alaska"]), + city: db.string(), + ...db.fields.timestamps(), + }) + .permission(defaultPermission) + .gqlPermission(defaultGqlPermission); diff --git a/example/tests/migration-fixtures/app/tailordb/user.ts b/example/tests/migration-fixtures/app/tailordb/user.ts new file mode 100644 index 000000000..ddd101a91 --- /dev/null +++ b/example/tests/migration-fixtures/app/tailordb/user.ts @@ -0,0 +1,11 @@ +import { db } from "@tailor-platform/sdk"; +import { defaultGqlPermission, defaultPermission } from "../permissions"; + +export const user = db + .table("User", { + name: db.string(), + email: db.string(), + ...db.fields.timestamps(), + }) + .permission(defaultPermission) + .gqlPermission(defaultGqlPermission); diff --git a/example/tests/migration-fixtures/steps/0000/tailordb/salesOrder.ts b/example/tests/migration-fixtures/steps/0000/tailordb/salesOrder.ts index 3f6891755..6bf2082d4 100644 --- a/example/tests/migration-fixtures/steps/0000/tailordb/salesOrder.ts +++ b/example/tests/migration-fixtures/steps/0000/tailordb/salesOrder.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const salesOrder = db - .type("SalesOrder", { + .table("SalesOrder", { customerID: db.uuid(), status: db.string({ optional: true }), totalPrice: db.int({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0000/tailordb/supplier.ts b/example/tests/migration-fixtures/steps/0000/tailordb/supplier.ts index ba1019ca9..d629a21ef 100644 --- a/example/tests/migration-fixtures/steps/0000/tailordb/supplier.ts +++ b/example/tests/migration-fixtures/steps/0000/tailordb/supplier.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const supplier = db - .type("Supplier", { + .table("Supplier", { name: db.string({ optional: true }), country: db.string({ optional: true }), phone: db.string(), diff --git a/example/tests/migration-fixtures/steps/0000/tailordb/user.ts b/example/tests/migration-fixtures/steps/0000/tailordb/user.ts index d70b9ade4..ddd101a91 100644 --- a/example/tests/migration-fixtures/steps/0000/tailordb/user.ts +++ b/example/tests/migration-fixtures/steps/0000/tailordb/user.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), ...db.fields.timestamps(), diff --git a/example/tests/migration-fixtures/steps/0001/tailordb/salesOrder.ts b/example/tests/migration-fixtures/steps/0001/tailordb/salesOrder.ts index 3f6891755..6bf2082d4 100644 --- a/example/tests/migration-fixtures/steps/0001/tailordb/salesOrder.ts +++ b/example/tests/migration-fixtures/steps/0001/tailordb/salesOrder.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const salesOrder = db - .type("SalesOrder", { + .table("SalesOrder", { customerID: db.uuid(), status: db.string({ optional: true }), totalPrice: db.int({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0001/tailordb/supplier.ts b/example/tests/migration-fixtures/steps/0001/tailordb/supplier.ts index ba1019ca9..d629a21ef 100644 --- a/example/tests/migration-fixtures/steps/0001/tailordb/supplier.ts +++ b/example/tests/migration-fixtures/steps/0001/tailordb/supplier.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const supplier = db - .type("Supplier", { + .table("Supplier", { name: db.string({ optional: true }), country: db.string({ optional: true }), phone: db.string(), diff --git a/example/tests/migration-fixtures/steps/0001/tailordb/user.ts b/example/tests/migration-fixtures/steps/0001/tailordb/user.ts index fd40638d8..cf63affb9 100644 --- a/example/tests/migration-fixtures/steps/0001/tailordb/user.ts +++ b/example/tests/migration-fixtures/steps/0001/tailordb/user.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), status: db.string({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0002/tailordb/salesOrder.ts b/example/tests/migration-fixtures/steps/0002/tailordb/salesOrder.ts index 3f6891755..6bf2082d4 100644 --- a/example/tests/migration-fixtures/steps/0002/tailordb/salesOrder.ts +++ b/example/tests/migration-fixtures/steps/0002/tailordb/salesOrder.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const salesOrder = db - .type("SalesOrder", { + .table("SalesOrder", { customerID: db.uuid(), status: db.string({ optional: true }), totalPrice: db.int({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0002/tailordb/supplier.ts b/example/tests/migration-fixtures/steps/0002/tailordb/supplier.ts index ba1019ca9..d629a21ef 100644 --- a/example/tests/migration-fixtures/steps/0002/tailordb/supplier.ts +++ b/example/tests/migration-fixtures/steps/0002/tailordb/supplier.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const supplier = db - .type("Supplier", { + .table("Supplier", { name: db.string({ optional: true }), country: db.string({ optional: true }), phone: db.string(), diff --git a/example/tests/migration-fixtures/steps/0002/tailordb/user.ts b/example/tests/migration-fixtures/steps/0002/tailordb/user.ts index f3872104b..992f421f7 100644 --- a/example/tests/migration-fixtures/steps/0002/tailordb/user.ts +++ b/example/tests/migration-fixtures/steps/0002/tailordb/user.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), status: db.string({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0003/tailordb/salesOrder.ts b/example/tests/migration-fixtures/steps/0003/tailordb/salesOrder.ts index 3f6891755..6bf2082d4 100644 --- a/example/tests/migration-fixtures/steps/0003/tailordb/salesOrder.ts +++ b/example/tests/migration-fixtures/steps/0003/tailordb/salesOrder.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const salesOrder = db - .type("SalesOrder", { + .table("SalesOrder", { customerID: db.uuid(), status: db.string({ optional: true }), totalPrice: db.int({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0003/tailordb/supplier.ts b/example/tests/migration-fixtures/steps/0003/tailordb/supplier.ts index ba1019ca9..d629a21ef 100644 --- a/example/tests/migration-fixtures/steps/0003/tailordb/supplier.ts +++ b/example/tests/migration-fixtures/steps/0003/tailordb/supplier.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const supplier = db - .type("Supplier", { + .table("Supplier", { name: db.string({ optional: true }), country: db.string({ optional: true }), phone: db.string(), diff --git a/example/tests/migration-fixtures/steps/0003/tailordb/user.ts b/example/tests/migration-fixtures/steps/0003/tailordb/user.ts index 288e6bd53..f403469b4 100644 --- a/example/tests/migration-fixtures/steps/0003/tailordb/user.ts +++ b/example/tests/migration-fixtures/steps/0003/tailordb/user.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), status: db.string({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0004/tailordb/salesOrder.ts b/example/tests/migration-fixtures/steps/0004/tailordb/salesOrder.ts index 3f6891755..6bf2082d4 100644 --- a/example/tests/migration-fixtures/steps/0004/tailordb/salesOrder.ts +++ b/example/tests/migration-fixtures/steps/0004/tailordb/salesOrder.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const salesOrder = db - .type("SalesOrder", { + .table("SalesOrder", { customerID: db.uuid(), status: db.string({ optional: true }), totalPrice: db.int({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0004/tailordb/supplier.ts b/example/tests/migration-fixtures/steps/0004/tailordb/supplier.ts index ba1019ca9..d629a21ef 100644 --- a/example/tests/migration-fixtures/steps/0004/tailordb/supplier.ts +++ b/example/tests/migration-fixtures/steps/0004/tailordb/supplier.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const supplier = db - .type("Supplier", { + .table("Supplier", { name: db.string({ optional: true }), country: db.string({ optional: true }), phone: db.string(), diff --git a/example/tests/migration-fixtures/steps/0004/tailordb/user.ts b/example/tests/migration-fixtures/steps/0004/tailordb/user.ts index a98e08378..069ca98ce 100644 --- a/example/tests/migration-fixtures/steps/0004/tailordb/user.ts +++ b/example/tests/migration-fixtures/steps/0004/tailordb/user.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), status: db.string({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0005/tailordb/salesOrder.ts b/example/tests/migration-fixtures/steps/0005/tailordb/salesOrder.ts index 1a88e6655..da886b93e 100644 --- a/example/tests/migration-fixtures/steps/0005/tailordb/salesOrder.ts +++ b/example/tests/migration-fixtures/steps/0005/tailordb/salesOrder.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const salesOrder = db - .type("SalesOrder", { + .table("SalesOrder", { customerID: db.uuid(), status: db.string({ optional: true }), totalPrice: db.int({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0005/tailordb/supplier.ts b/example/tests/migration-fixtures/steps/0005/tailordb/supplier.ts index d04c84bd5..29363494a 100644 --- a/example/tests/migration-fixtures/steps/0005/tailordb/supplier.ts +++ b/example/tests/migration-fixtures/steps/0005/tailordb/supplier.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const supplier = db - .type("Supplier", { + .table("Supplier", { name: db.string(), country: db.string(), phone: db.string(), diff --git a/example/tests/migration-fixtures/steps/0005/tailordb/user.ts b/example/tests/migration-fixtures/steps/0005/tailordb/user.ts index 5da16e6e4..f0c68fa5d 100644 --- a/example/tests/migration-fixtures/steps/0005/tailordb/user.ts +++ b/example/tests/migration-fixtures/steps/0005/tailordb/user.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), status: db.string({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0006/tailordb/salesOrder.ts b/example/tests/migration-fixtures/steps/0006/tailordb/salesOrder.ts index 1a88e6655..da886b93e 100644 --- a/example/tests/migration-fixtures/steps/0006/tailordb/salesOrder.ts +++ b/example/tests/migration-fixtures/steps/0006/tailordb/salesOrder.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const salesOrder = db - .type("SalesOrder", { + .table("SalesOrder", { customerID: db.uuid(), status: db.string({ optional: true }), totalPrice: db.int({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0006/tailordb/supplier.ts b/example/tests/migration-fixtures/steps/0006/tailordb/supplier.ts index d04c84bd5..29363494a 100644 --- a/example/tests/migration-fixtures/steps/0006/tailordb/supplier.ts +++ b/example/tests/migration-fixtures/steps/0006/tailordb/supplier.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const supplier = db - .type("Supplier", { + .table("Supplier", { name: db.string(), country: db.string(), phone: db.string(), diff --git a/example/tests/migration-fixtures/steps/0006/tailordb/user.ts b/example/tests/migration-fixtures/steps/0006/tailordb/user.ts index e4e6fd5a0..e8a614be8 100644 --- a/example/tests/migration-fixtures/steps/0006/tailordb/user.ts +++ b/example/tests/migration-fixtures/steps/0006/tailordb/user.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const user = db - .type("User", { + .table("User", { name: db.string().index().unique(), email: db.string(), status: db.string({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0007/tailordb/salesOrder.ts b/example/tests/migration-fixtures/steps/0007/tailordb/salesOrder.ts index 1a88e6655..da886b93e 100644 --- a/example/tests/migration-fixtures/steps/0007/tailordb/salesOrder.ts +++ b/example/tests/migration-fixtures/steps/0007/tailordb/salesOrder.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const salesOrder = db - .type("SalesOrder", { + .table("SalesOrder", { customerID: db.uuid(), status: db.string({ optional: true }), totalPrice: db.int({ optional: true }), diff --git a/example/tests/migration-fixtures/steps/0007/tailordb/supplier.ts b/example/tests/migration-fixtures/steps/0007/tailordb/supplier.ts index d04c84bd5..29363494a 100644 --- a/example/tests/migration-fixtures/steps/0007/tailordb/supplier.ts +++ b/example/tests/migration-fixtures/steps/0007/tailordb/supplier.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const supplier = db - .type("Supplier", { + .table("Supplier", { name: db.string(), country: db.string(), phone: db.string(), diff --git a/example/tests/migration-fixtures/steps/0007/tailordb/user.ts b/example/tests/migration-fixtures/steps/0007/tailordb/user.ts index 4f95072b1..c5496b60c 100644 --- a/example/tests/migration-fixtures/steps/0007/tailordb/user.ts +++ b/example/tests/migration-fixtures/steps/0007/tailordb/user.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "../permissions"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), status: db.string({ optional: true }), diff --git a/example/tests/scripts/generate_files.ts b/example/tests/scripts/generate_files.ts index fb29fe371..668d67a43 100644 --- a/example/tests/scripts/generate_files.ts +++ b/example/tests/scripts/generate_files.ts @@ -6,7 +6,7 @@ import { generate, deploy } from "@tailor-platform/sdk/cli"; // Disable inline sourcemaps during test fixture generation so that snapshot // comparisons remain stable across environments. -process.env.TAILOR_ENABLE_INLINE_SOURCEMAP ??= "false"; +process.env.TAILOR_INLINE_SOURCEMAP ??= "false"; const __filename = url.fileURLToPath(import.meta.url); @@ -52,7 +52,7 @@ export async function generateExpectedFiles(): Promise { console.log("Removed existing expected directory"); } - process.env.TAILOR_SDK_OUTPUT_DIR = expectedDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = expectedDir; await generate({ configPath: "./tests/tailor.config.expected.ts", }); @@ -92,7 +92,6 @@ async function listGeneratedFiles(dirPath: string, depth = 0, maxDepth = 3): Pro } } -const generatorsCompatDir = "tests/fixtures/generators"; const pluginsCompatDir = "tests/fixtures/plugins"; function bundledScriptFileName(kind: string, name: string): string { @@ -103,14 +102,13 @@ function bundledScriptFileName(kind: string, name: string): string { } export async function generateCompatFiles(): Promise { - for (const dir of [generatorsCompatDir, pluginsCompatDir]) { + for (const dir of [pluginsCompatDir]) { if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true }); } - await generate({ configPath: "./tests/tailor.config.generators-compat.ts" }); await generate({ configPath: "./tests/tailor.config.plugins-compat.ts" }); // Also run deploy --buildOnly for plugins-compat (used by bundled_execution tests) - process.env.TAILOR_SDK_OUTPUT_DIR = pluginsCompatDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = pluginsCompatDir; const result = await deploy({ configPath: "./tests/tailor.config.plugins-compat.ts", buildOnly: true, diff --git a/example/tests/scripts/migration_e2e.ts b/example/tests/scripts/migration_e2e.ts index 0b98c2226..5fb48ac00 100644 --- a/example/tests/scripts/migration_e2e.ts +++ b/example/tests/scripts/migration_e2e.ts @@ -14,6 +14,8 @@ import { } from "@tailor-platform/sdk/cli"; import { AuthInvokerSchema } from "@tailor-platform/tailor-proto/auth_resource_pb"; +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const exampleDir = path.resolve(scriptDir, "..", ".."); const fixtureRoot = path.resolve(exampleDir, "tests", "migration-fixtures"); @@ -27,15 +29,15 @@ const templateMigrationsDir = path.resolve(fixtureRoot, "templates"); const namespace = "migrationdb"; const machineUserName = "manager-machine-user"; -const tailorSdkBin = path.resolve( +const tailorBin = path.resolve( exampleDir, "node_modules", ".bin", - process.platform === "win32" ? "tailor-sdk.cmd" : "tailor-sdk", + process.platform === "win32" ? "tailor.cmd" : "tailor", ); -const runTailorSdk = (args: string[]) => { - execFileSync(tailorSdkBin, args, { +const runTailor = (args: string[]) => { + execFileSync(tailorBin, args, { cwd: appDir, env: { ...process.env, @@ -45,11 +47,11 @@ const runTailorSdk = (args: string[]) => { }; const runDeploy = () => { - runTailorSdk(["deploy", "-c", configPath, "--yes"]); + runTailor(["deploy", "-c", configPath, "--yes"]); }; const runMigrateGenerate = () => { - runTailorSdk(["tailordb", "migration", "generate", "-c", configPath, "--yes"]); + runTailor(["tailordb", "migration", "generate", "-c", configPath, "--yes"]); }; const resetMigrations = () => { @@ -270,7 +272,7 @@ const seedData = async ( workspaceId, name: `${label}.js`, code: bundled.bundledCode, - arg: JSON.stringify({ data, order }), + arg: { data, order } as unknown as JsonValue, invoker, }); if (!result.success) { diff --git a/example/tests/tailor.config.generators-compat.ts b/example/tests/tailor.config.generators-compat.ts deleted file mode 100644 index 36a32aec5..000000000 --- a/example/tests/tailor.config.generators-compat.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineGenerators } from "@tailor-platform/sdk"; -import config, { auth } from "../tailor.config"; -export default config; -export { auth }; -export const generators = defineGenerators( - ["@tailor-platform/kysely-type", { distPath: "./tests/fixtures/generators/db.ts" }], - ["@tailor-platform/enum-constants", { distPath: "./tests/fixtures/generators/enums.ts" }], - ["@tailor-platform/file-utils", { distPath: "./tests/fixtures/generators/files.ts" }], - [ - "@tailor-platform/seed", - { distPath: "./tests/fixtures/generators/seed", machineUserName: "manager-machine-user" }, - ], -); diff --git a/example/vitest.config.ts b/example/vitest.config.ts index d9abe067b..3d48ee0a1 100644 --- a/example/vitest.config.ts +++ b/example/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ // Disable inline sourcemaps during tests to keep bundled output stable // for size and fixture comparisons. env: { - TAILOR_ENABLE_INLINE_SOURCEMAP: "false", + TAILOR_INLINE_SOURCEMAP: "false", }, projects: [ { diff --git a/example/workflows/approval.ts b/example/workflows/approval.ts index 6c113e77c..5266b45fd 100644 --- a/example/workflows/approval.ts +++ b/example/workflows/approval.ts @@ -1,6 +1,6 @@ -import { createWorkflow, createWorkflowJob, defineWaitPoints } from "@tailor-platform/sdk"; +import { createWaitPoints, createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; -export const { approval } = defineWaitPoints((define) => ({ +export const { approval } = createWaitPoints((define) => ({ /** Approval for order processing */ approval: define<{ message: string; requestId: string }, { approved: boolean }>(), })); diff --git a/example/workflows/jobs/fetch-customer.ts b/example/workflows/jobs/fetch-customer.ts index 98bd60bf0..d7891370e 100644 --- a/example/workflows/jobs/fetch-customer.ts +++ b/example/workflows/jobs/fetch-customer.ts @@ -14,7 +14,7 @@ export const fetchCustomer = createWorkflowJob({ return { ...customer, createdAt: customer.createdAt.toISOString(), - updatedAt: customer.updatedAt?.toISOString() ?? null, + updatedAt: customer.updatedAt.toISOString(), }; }, }); diff --git a/example/workflows/order-processing.ts b/example/workflows/order-processing.ts index da0d512b2..6bba8a814 100644 --- a/example/workflows/order-processing.ts +++ b/example/workflows/order-processing.ts @@ -6,12 +6,12 @@ import { sendNotification } from "./jobs/send-notification"; export const processOrder = createWorkflowJob({ name: "process-order", - body: async (input: { orderId: string; customerId: string }, { env }) => { + body: (input: { orderId: string; customerId: string }, { env }) => { // Log env for demonstration console.log("Environment:", env); - // Fetch customer information using trigger - const customer = await fetchCustomer.trigger({ + // Fetch customer information using start + const customer = fetchCustomer.start({ customerId: input.customerId, }); @@ -19,8 +19,8 @@ export const processOrder = createWorkflowJob({ throw new Error(`Customer ${input.customerId} not found`); } - // Send notification to customer using trigger - const notification = await sendNotification.trigger({ + // Send notification to customer using start + const notification = sendNotification.start({ message: `Your order ${input.orderId} is being processed`, recipient: customer.email, }); diff --git a/example/workflows/sample.ts b/example/workflows/sample.ts index 49956d92e..e6cbfddca 100644 --- a/example/workflows/sample.ts +++ b/example/workflows/sample.ts @@ -21,10 +21,10 @@ export const check_inventory = createWorkflowJob({ export const validate_order = createWorkflowJob({ name: "validate-order", - body: async (input: { orderId: string }) => { + body: (input: { orderId: string }) => { console.log("Order ID:", input.orderId); - const inventoryResult = await check_inventory.trigger(); - const paymentResult = await process_payment.trigger(); + const inventoryResult = check_inventory.start(); + const paymentResult = process_payment.start(); return { inventoryResult, paymentResult }; }, }); diff --git a/lefthook.yml b/lefthook.yml index 004b68c5b..454d4752a 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -19,7 +19,7 @@ pre-commit: - "pnpm-debug.log*" - "lerna-debug.log*" - "coverage/**" - - ".tailor-sdk/**" + - ".tailor/**" - "example/tests/fixtures/**" - "example/generated/**" - "example/seed/**" diff --git a/llm-challenge/.oxlintrc.json b/llm-challenge/.oxlintrc.json index c3ba83401..065b8506d 100644 --- a/llm-challenge/.oxlintrc.json +++ b/llm-challenge/.oxlintrc.json @@ -2,6 +2,10 @@ "$schema": "./node_modules/oxlint/configuration_schema.json", "rules": { "unicorn/no-array-reverse": "error", - "unicorn/no-array-sort": "error" - } + "unicorn/no-array-sort": "error", + "tailor-zod/require-object-policy-comment": "error", + "zod/prefer-loose-object": "error", + "zod/prefer-strict-object": "error" + }, + "jsPlugins": ["eslint-plugin-zod", "../scripts/oxlint/tailor-zod-plugin.cjs"] } diff --git a/llm-challenge/package.json b/llm-challenge/package.json index 4c6301565..9417c7aed 100644 --- a/llm-challenge/package.json +++ b/llm-challenge/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@types/node": "24.13.3", + "eslint-plugin-zod": "4.7.0", "oxlint": "1.73.0", "oxlint-tsgolint": "0.24.0", "tsx": "4.23.1", @@ -19,6 +20,6 @@ "vitest": "4.1.10" }, "engines": { - "node": ">= 22.14.0" + "node": ">= 22.15.0" } } diff --git a/llm-challenge/problems/cli/generate/prompt.md b/llm-challenge/problems/cli/generate/prompt.md index 4a2b3852c..66b266b5e 100644 --- a/llm-challenge/problems/cli/generate/prompt.md +++ b/llm-challenge/problems/cli/generate/prompt.md @@ -1,4 +1,4 @@ -Use the local `tailor-sdk` binary available after installing dependencies. +Use the local `tailor` binary available after installing dependencies. Set up a minimal Tailor SDK project for a task list application, then use the CLI to produce the generated project artifacts. Do not rely on a globally installed SDK. diff --git a/llm-challenge/problems/cli/help-error-recovery/prompt.md b/llm-challenge/problems/cli/help-error-recovery/prompt.md index 51b2ed798..54692179a 100644 --- a/llm-challenge/problems/cli/help-error-recovery/prompt.md +++ b/llm-challenge/problems/cli/help-error-recovery/prompt.md @@ -1,4 +1,4 @@ -Use the local `tailor-sdk` binary available after installing dependencies. +Use the local `tailor` binary available after installing dependencies. Start by using the CLI help and error output to discover how to perform a simple project maintenance action. Then run the corrected command and leave a short note in the workspace describing what you ran and why. diff --git a/llm-challenge/problems/cli/help-error-recovery/verify.json b/llm-challenge/problems/cli/help-error-recovery/verify.json index e2360bb18..a6538fb54 100644 --- a/llm-challenge/problems/cli/help-error-recovery/verify.json +++ b/llm-challenge/problems/cli/help-error-recovery/verify.json @@ -12,7 +12,7 @@ "id": "note-names-local-cli", "kind": "content-match", "glob": "**/*.md", - "pattern": "tailor-sdk|pnpm", + "pattern": "tailor|pnpm", "flags": "i", "description": "maintenance note names the local CLI command path" } diff --git a/llm-challenge/problems/cli/tailordb-migrate-generate/prompt.md b/llm-challenge/problems/cli/tailordb-migrate-generate/prompt.md index 98b7366b6..96d083d68 100644 --- a/llm-challenge/problems/cli/tailordb-migrate-generate/prompt.md +++ b/llm-challenge/problems/cli/tailordb-migrate-generate/prompt.md @@ -1,4 +1,4 @@ -Use the local `tailor-sdk` binary available after installing dependencies. +Use the local `tailor` binary available after installing dependencies. Create a minimal TailorDB model change for a customer directory, then use the CLI to create the migration artifacts needed for that change. Discover the necessary command shape from the CLI itself rather than assuming a global tool. diff --git a/llm-challenge/problems/cli/tailordb-migrate-script/prompt.md b/llm-challenge/problems/cli/tailordb-migrate-script/prompt.md index 655b902d4..ba9003835 100644 --- a/llm-challenge/problems/cli/tailordb-migrate-script/prompt.md +++ b/llm-challenge/problems/cli/tailordb-migrate-script/prompt.md @@ -1,4 +1,4 @@ -Use the local `tailor-sdk` binary available after installing dependencies. +Use the local `tailor` binary available after installing dependencies. Create a minimal TailorDB schema evolution for an inventory item and use the CLI to produce the executable migration script for it. Discover the needed command shape from local help and project files. diff --git a/llm-challenge/problems/sdk-api/tailordb-array-unique-recovery/scaffold/tailordb/creator-profile.ts b/llm-challenge/problems/sdk-api/tailordb-array-unique-recovery/scaffold/tailordb/creator-profile.ts index b05beec54..9b31b25f3 100644 --- a/llm-challenge/problems/sdk-api/tailordb-array-unique-recovery/scaffold/tailordb/creator-profile.ts +++ b/llm-challenge/problems/sdk-api/tailordb-array-unique-recovery/scaffold/tailordb/creator-profile.ts @@ -1,7 +1,7 @@ import { db } from "@tailor-platform/sdk"; export const CreatorProfile = db - .type("CreatorProfile", { + .table("CreatorProfile", { handle: db.string().unique(), displayName: db.string(), tags: db.string({ array: true }).unique(), diff --git a/llm-challenge/src/challenge.test.ts b/llm-challenge/src/challenge.test.ts index b5c559e1b..8857239c7 100644 --- a/llm-challenge/src/challenge.test.ts +++ b/llm-challenge/src/challenge.test.ts @@ -300,12 +300,12 @@ describe("artifact summary", () => { await fs.mkdir(path.join(worktreePath, "src"), { recursive: true }); await fs.mkdir(path.join(worktreePath, "node_modules/pkg"), { recursive: true }); await fs.mkdir(path.join(worktreePath, ".pnpm-home/store"), { recursive: true }); - await fs.mkdir(path.join(worktreePath, ".tailor-sdk/cache"), { recursive: true }); + await fs.mkdir(path.join(worktreePath, ".tailor/cache"), { recursive: true }); await fs.mkdir(path.join(worktreePath, ".turbo/cache"), { recursive: true }); await fs.writeFile(path.join(worktreePath, "src/app.ts"), "export {};\n"); await fs.writeFile(path.join(worktreePath, "node_modules/pkg/index.js"), ""); await fs.writeFile(path.join(worktreePath, ".pnpm-home/store/index.db"), ""); - await fs.writeFile(path.join(worktreePath, ".tailor-sdk/cache/generated.json"), "{}"); + await fs.writeFile(path.join(worktreePath, ".tailor/cache/generated.json"), "{}"); await fs.writeFile(path.join(worktreePath, ".turbo/cache/state.json"), "{}"); await runCommand("git", ["init"], { cwd: worktreePath }); @@ -375,7 +375,7 @@ describe("artifact summary", () => { expect(summary.files).toContain("src/app.ts"); expect(summary.files).not.toContain("node_modules/pkg/index.js"); expect(summary.files).not.toContain(".pnpm-home/store/index.db"); - expect(summary.files).not.toContain(".tailor-sdk/cache/generated.json"); + expect(summary.files).not.toContain(".tailor/cache/generated.json"); expect(summary.files).not.toContain(".turbo/cache/state.json"); expect(summary.gitStatus).toContain("?? src/app.ts"); expect(summary.commands.map((command) => command.command)).toEqual([ @@ -954,12 +954,12 @@ describe("verification summary", () => { const problemRoot = path.join(dir, "problem"); const worktreePath = path.join(dir, "work"); await fs.mkdir(path.join(problemRoot, "scaffold"), { recursive: true }); - await fs.mkdir(path.join(worktreePath, ".tailor-sdk/cache"), { recursive: true }); + await fs.mkdir(path.join(worktreePath, ".tailor/cache"), { recursive: true }); await fs.mkdir(path.join(worktreePath, "src"), { recursive: true }); await fs.writeFile(path.join(worktreePath, "package.json"), "{}\n"); - await fs.writeFile(path.join(worktreePath, ".tailor-sdk/cache/generated.ts"), "cacheOnly\n"); + await fs.writeFile(path.join(worktreePath, ".tailor/cache/generated.ts"), "cacheOnly\n"); await fs.symlink( - path.join(worktreePath, ".tailor-sdk/cache/generated.ts"), + path.join(worktreePath, ".tailor/cache/generated.ts"), path.join(worktreePath, "src/cache-alias.ts"), ); const verifyPath = path.join(problemRoot, "verify.json"); @@ -1162,7 +1162,7 @@ describe("workspace preparation", () => { const dir = await makeTempDir(); const worktreePath = path.join(dir, "work"); await Promise.all( - ["node_modules", ".pnpm-store", ".pnpm-home", ".cache", ".turbo", ".tailor-sdk/cache"].map( + ["node_modules", ".pnpm-store", ".pnpm-home", ".cache", ".turbo", ".tailor/cache"].map( (name) => fs.mkdir(path.join(worktreePath, name), { recursive: true }), ), ); @@ -1175,7 +1175,7 @@ describe("workspace preparation", () => { ".pnpm-home", ".cache", ".turbo", - ".tailor-sdk/cache", + ".tailor/cache", ]) { await expect(fs.access(path.join(worktreePath, name))).rejects.toThrow("ENOENT"); } diff --git a/llm-challenge/src/workspace-files.ts b/llm-challenge/src/workspace-files.ts index 831d651b9..2ca44b860 100644 --- a/llm-challenge/src/workspace-files.ts +++ b/llm-challenge/src/workspace-files.ts @@ -11,7 +11,7 @@ const EXCLUDED_DIRS = new Set([ ".turbo", "node_modules", ]); -const EXCLUDED_PATHS = new Set([".tailor-sdk/cache"]); +const EXCLUDED_PATHS = new Set([".tailor/cache"]); export function isExcludedWorkspacePath(relativePath: string): boolean { const segments = relativePath.split("/"); diff --git a/llm-challenge/src/workspace.ts b/llm-challenge/src/workspace.ts index 2e5fae8e7..d00a0d2c6 100644 --- a/llm-challenge/src/workspace.ts +++ b/llm-challenge/src/workspace.ts @@ -43,7 +43,7 @@ const GITIGNORE_PATTERNS = [ ".pnpm-store/", ".pnpm-home/", ".cache/", - ".tailor-sdk/cache/", + ".tailor/cache/", ]; export async function prepareWorkspace(options: { @@ -82,8 +82,8 @@ export function profileForProblem( export async function pruneWorkspaceDeps(worktreePath: string): Promise { await Promise.all( - ["node_modules", ".pnpm-store", ".pnpm-home", ".cache", ".turbo", ".tailor-sdk/cache"].map( - (name) => fs.rm(path.join(worktreePath, name), { recursive: true, force: true }), + ["node_modules", ".pnpm-store", ".pnpm-home", ".cache", ".turbo", ".tailor/cache"].map((name) => + fs.rm(path.join(worktreePath, name), { recursive: true, force: true }), ), ); } diff --git a/oxlint.vitest.json b/oxlint.vitest.json index 8fcd798ab..1edb5b812 100644 --- a/oxlint.vitest.json +++ b/oxlint.vitest.json @@ -9,7 +9,7 @@ "style": "off", "restriction": "off" }, - "ignorePatterns": ["**/node_modules/**", "**/dist/**", "**/.agent/**", "**/.tailor-sdk/**"], + "ignorePatterns": ["**/node_modules/**", "**/dist/**", "**/.agent/**", "**/.tailor/**"], "rules": { "vitest/consistent-each-for": "error", "vitest/consistent-test-it": [ diff --git a/package.json b/package.json index 78b43cc7b..15bb4c678 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,12 @@ "deploy": "pnpm run build && pnpm -r run deploy", "agent:rules:update": "node scripts/sync-agent-rules.mjs --write", "agent:rules:check": "node scripts/sync-agent-rules.mjs --check", + "codemod:docs:update": "tsx packages/sdk-codemod/scripts/sync-codemod-docs.ts --write", + "codemod:docs:check": "tsx packages/sdk-codemod/scripts/sync-codemod-docs.ts --check", + "codemod:resolve-pending": "tsx packages/sdk-codemod/scripts/resolve-pending-boundaries.ts", "check": "pnpm run generate && pnpm run format && pnpm run \"/^check:/\"", "check:agent-rules": "pnpm run agent:rules:check", + "check:codemod-docs": "pnpm run codemod:docs:check", "check:lint": "pnpm -r run lint:fix && pnpm run lint:vitest", "check:typecheck": "pnpm run --no-bail \"/^check-type:/\"", "check-type:root": "pnpm run typecheck:root", @@ -46,6 +50,7 @@ "@changesets/cli": "3.0.0-next.8", "@tailor-platform/sdk": "workspace:^", "@types/node": "24.13.3", + "eslint-plugin-zod": "4.7.0", "knip": "6.26.0", "lefthook": "2.1.10", "madge": "8.0.0", @@ -57,7 +62,7 @@ "typescript": "6.0.3" }, "engines": { - "node": ">= 22.14.0" + "node": ">= 22.15.0" }, "packageManager": "pnpm@11.11.0" } diff --git a/packages/create-sdk/.oxlintrc.json b/packages/create-sdk/.oxlintrc.json index fdce5f9cf..b39d54bbd 100644 --- a/packages/create-sdk/.oxlintrc.json +++ b/packages/create-sdk/.oxlintrc.json @@ -63,7 +63,10 @@ "typescript/prefer-includes": "error", "typescript/prefer-nullish-coalescing": "error", "typescript/prefer-regexp-exec": "error", - "typescript/prefer-string-starts-ends-with": "error" + "typescript/prefer-string-starts-ends-with": "error", + "tailor-zod/require-object-policy-comment": "error", + "zod/prefer-loose-object": "error", + "zod/prefer-strict-object": "error" }, "overrides": [ { @@ -156,5 +159,6 @@ "node": true } } - ] + ], + "jsPlugins": ["eslint-plugin-zod", "../../scripts/oxlint/tailor-zod-plugin.cjs"] } diff --git a/packages/create-sdk/CHANGELOG.md b/packages/create-sdk/CHANGELOG.md index b1eed0073..aedf22b66 100644 --- a/packages/create-sdk/CHANGELOG.md +++ b/packages/create-sdk/CHANGELOG.md @@ -1,20 +1,120 @@ # @tailor-platform/create-sdk -## 1.79.0 +## 2.0.0-next.8 -## 1.78.0 +## 2.0.0-next.7 + +### Major Changes + +- [#1782](https://github.com/tailor-platform/sdk/pull/1782) [`c971797`](https://github.com/tailor-platform/sdk/commit/c971797c9bfa035a43771c46f2b1c3bd93f989a9) Thanks [@toiroakr](https://github.com/toiroakr)! - Rename `Workflow.trigger()` (returned by `createWorkflow()`) and `WorkflowJob.trigger()` (returned by `createWorkflowJob()`) to `.start()`, aligning the SDK's ergonomic verb with the platform's `start*` RPC vocabulary: + + ```diff + const inventory = checkInventory.trigger({ orderId: input.orderId }); + +const inventory = checkInventory.start({ orderId: input.orderId }); + + -const workflowRunId = await orderProcessingWorkflow.trigger(args, { invoker: "manager" }); + +const workflowRunId = await orderProcessingWorkflow.start(args, { invoker: "manager" }); + ``` + + `mockWorkflow()`'s `wf.job(definition)` / `wf.workflow(definition)` now return a mock of the `.start` method, and `wf.setTriggerHandler` / `wf.triggeredJobs` are renamed to `wf.setStartHandler` / `wf.startedJobs`. No codemod ships for the `.trigger()` → `.start()` call-site rename itself — see the `v2/workflow-start-rename` migration guide entry for manual migration steps. + +### Minor Changes + +- [#1737](https://github.com/tailor-platform/sdk/pull/1737) [`e349b9e`](https://github.com/tailor-platform/sdk/commit/e349b9e3d9c61f324f21dea92dd08055493a2c6d) Thanks [@dqn](https://github.com/dqn)! - Add lint rules that flag the external /api prefix in HTTP adapter path patterns and permission settings that grant access unconditionally, and enable them in newly scaffolded projects. + +## 2.0.0-next.6 + +## 2.0.0-next.5 ### Patch Changes - [#1753](https://github.com/tailor-platform/sdk/pull/1753) [`6bff945`](https://github.com/tailor-platform/sdk/commit/6bff94505f3dbe11a8be36ef301e3641ee2cba89) Thanks [@dqn](https://github.com/dqn)! - Add typed, service-specific Vitest mock controls for runtime APIs and update generated project tests to use them. -## 1.77.0 +## 2.0.0-next.4 + +### Major Changes + +- [#1693](https://github.com/tailor-platform/sdk/pull/1693) [`4751214`](https://github.com/tailor-platform/sdk/commit/4751214c0923e094a844f9ce322279a47e871075) Thanks [@dqn](https://github.com/dqn)! - Rename the TailorDB schema builder from `db.type()` to `db.table()`. + + Update TailorDB definitions: + + ```diff + import { db } from "@tailor-platform/sdk"; + + -export const user = db.type("User", { + +export const user = db.table("User", { + name: db.string(), + }); + ``` + +### Patch Changes + +- [#1704](https://github.com/tailor-platform/sdk/pull/1704) [`9c81d9c`](https://github.com/tailor-platform/sdk/commit/9c81d9c18b1d29b3e9307ea17fe54c8ce55f4dda) Thanks [@dqn](https://github.com/dqn)! - Remove flat value and default exports from `@tailor-platform/sdk/runtime/*` subpath modules. Import each subpath through its self-named namespace export instead, for example `import { iconv } from "@tailor-platform/sdk/runtime/iconv"`. + + The aggregate `@tailor-platform/sdk/runtime` entry remains named-only, and its deprecated `file.deleteFile` alias is removed in favor of `file.delete`. The v2 codemod rewrites straightforward namespace-star subpath imports, flat named value imports, and aggregate `file.deleteFile` calls to the new namespace-object style. + + `TailorContextAPI` and `TailorWorkflowAPI` now describe the SDK wrapper objects. Code that types the platform-provided `globalThis.tailor.context` or `globalThis.tailor.workflow` objects directly must use `PlatformContextAPI` or `PlatformWorkflowAPI` instead. + +## 2.0.0-next.3 + +### Major Changes + +- [#1684](https://github.com/tailor-platform/sdk/pull/1684) [`de3ef5e`](https://github.com/tailor-platform/sdk/commit/de3ef5e7421a998624154df5e90da62e17664524) Thanks [@dqn](https://github.com/dqn)! - Restore Tailor field outputs for UUID, date, datetime, time, and decimal fields to plain string-compatible types and remove the strict scalar string migration guidance. + +- [#1556](https://github.com/tailor-platform/sdk/pull/1556) [`645949e`](https://github.com/tailor-platform/sdk/commit/645949ed64bda8b82fc44c0db54928698b12a2eb) Thanks [@toiroakr](https://github.com/toiroakr)! - Rename `defineWaitPoint` and `defineWaitPoints` to `createWaitPoint` and `createWaitPoints`. + + These functions create runtime instances with `.wait()` and `.resolve()` methods that call the platform API at runtime, so the `create*` prefix is more accurate. Update any usages: + + ```diff + -import { defineWaitPoint, defineWaitPoints } from "@tailor-platform/sdk"; + +import { createWaitPoint, createWaitPoints } from "@tailor-platform/sdk"; + + -export const approval = defineWaitPoint("approval"); + +export const approval = createWaitPoint("approval"); + + -export const waitPoints = defineWaitPoints((define) => ({ ... })); + +export const waitPoints = createWaitPoints((define) => ({ ... })); + ``` + +### Patch Changes + +- [#1559](https://github.com/tailor-platform/sdk/pull/1559) [`ff8ef1c`](https://github.com/tailor-platform/sdk/commit/ff8ef1c1323daf81812c182e146fd53da20e676e) Thanks [@dqn](https://github.com/dqn)! - Rename auth attribute module augmentation from `AttributeMap` to `Attributes`. + +- [#1563](https://github.com/tailor-platform/sdk/pull/1563) [`501e8bf`](https://github.com/tailor-platform/sdk/commit/501e8bfdd2bca7201a1c9b036bf72087476da416) Thanks [@dqn](https://github.com/dqn)! - Standardize SDK-owned environment variables on the `TAILOR_*` namespace. + + Replace the removed SDK-specific environment variables with their new names: `TAILOR_CONFIG_PATH`, `TAILOR_DTS_PATH`, `TAILOR_CI_ALLOW_ID_INJECTION`, `TAILOR_DEPLOY_BUILD_ONLY`, `TAILOR_BUILD_OUTPUT_DIR`, `TAILOR_SKILLS_SOURCE`, `TAILOR_TEMPLATE_SDK_VERSION`, `TAILOR_PLATFORM_URL`, `TAILOR_PLATFORM_OAUTH2_CLIENT_ID`, `TAILOR_INLINE_SOURCEMAP`, `TAILOR_QUERY_NEWLINE_ON_ENTER`, and `TAILOR_APP_LOG_LEVEL`. The deprecated `TAILOR_TOKEN` fallback is removed; use `TAILOR_PLATFORM_TOKEN`. The v2 codemod rewrites unambiguous removed SDK environment variable names and flags generic names such as `LOG_LEVEL` and `PLATFORM_URL` for manual review. + +## 2.0.0-next.2 +### Major Changes + -## 1.76.2 -## 1.76.1 +- [#1498](https://github.com/tailor-platform/sdk/pull/1498) [`83145db`](https://github.com/tailor-platform/sdk/commit/83145db9a0d243aa68c1b641c2b6026771a62188) Thanks [@dqn](https://github.com/dqn)! - Set `db.fields.timestamps()` `updatedAt` when records are created and make the generated field non-null. `createdAt` keeps its existing create-time behavior, while `updatedAt` keeps its update-time behavior and now also gets a create hook that preserves provided values and falls back to the current time. -## 1.76.0 + Update create-sdk templates so scaffolded projects use the new non-null `updatedAt` Kysely types and seed schemas. + + Existing TailorDB schemas that already use this helper will change `updatedAt` from optional to required. Backfill existing records that have `updatedAt: null` before applying the schema change. + +### Patch Changes + + + +- [#1509](https://github.com/tailor-platform/sdk/pull/1509) [`7cadaa7`](https://github.com/tailor-platform/sdk/commit/7cadaa7c4987b81130ca80ba80bc5d5b26276394) Thanks [@dqn](https://github.com/dqn)! - Rename resolver, executor, workflow trigger, and typed workflow start machine-user options from `authInvoker` to `invoker`. + + Update create-sdk templates and the v2 auth invoker codemod to generate the new `invoker` option. + + +- [#1484](https://github.com/tailor-platform/sdk/pull/1484) [`a376dc8`](https://github.com/tailor-platform/sdk/commit/a376dc8cd053d20744c90104e8b44ed2729ffe8c) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated `openDownloadStream` file streaming API. Use `downloadStream` for streamed file downloads. + + The generated file utilities now emit `downloadFileStream`, which calls `downloadStream` and returns `FileDownloadStreamResponse`, instead of the removed `openFileDownloadStream` helper. + +## 2.0.0-next.1 + +## 2.0.0-next.0 + +## 1.66.0 + +## 1.71.0 ## 1.75.0 @@ -32,8 +132,6 @@ ## 1.72.0 -## 1.71.0 - ## 1.70.1 ### Patch Changes @@ -59,7 +157,6 @@ ## 1.66.1 ## 1.66.0 - ## 1.65.0 ## 1.64.0 diff --git a/packages/create-sdk/package.json b/packages/create-sdk/package.json index 204225cd6..a9c04b10a 100644 --- a/packages/create-sdk/package.json +++ b/packages/create-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@tailor-platform/create-sdk", - "version": "1.79.0", + "version": "2.0.0-next.8", "description": "A CLI tool to quickly create a new Tailor Platform SDK project", "license": "MIT", "repository": { @@ -35,6 +35,7 @@ }, "devDependencies": { "@types/node": "24.13.3", + "eslint-plugin-zod": "4.7.0", "oxlint": "1.73.0", "oxlint-tsgolint": "0.24.0", "tsdown": "0.22.7", diff --git a/packages/create-sdk/scripts/prepare-templates.js b/packages/create-sdk/scripts/prepare-templates.js index 2e853f027..c1b06694a 100644 --- a/packages/create-sdk/scripts/prepare-templates.js +++ b/packages/create-sdk/scripts/prepare-templates.js @@ -4,12 +4,12 @@ import { readFileSync, writeFileSync, readdirSync, existsSync, copyFileSync } fr import { resolve } from "node:path"; // Get SDK version or URL from environment variable or package.json -const sdkVersionOrUrl = process.env.TAILOR_SDK_VERSION; -const eslintPluginVersionOrUrl = process.env.TAILOR_SDK_ESLINT_PLUGIN_VERSION; +const sdkVersionOrUrl = process.env.TAILOR_TEMPLATE_SDK_VERSION; +const eslintPluginVersionOrUrl = process.env.TAILOR_TEMPLATE_ESLINT_PLUGIN_VERSION; let sdkVersion; if (sdkVersionOrUrl) { - // If TAILOR_SDK_VERSION is set, use it (can be version string or pkg-pr-new URL) + // 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 { diff --git a/packages/create-sdk/src/context.ts b/packages/create-sdk/src/context.ts index 5cd54d5c6..c96973677 100644 --- a/packages/create-sdk/src/context.ts +++ b/packages/create-sdk/src/context.ts @@ -31,7 +31,7 @@ const templateHints: Record = { workflow: "Workflow patterns with job chaining and testing", executor: "Executor trigger types (record, resolver, schedule, webhook)", "static-web-site": "Static website with auth and IdP integration", - generators: "Built-in generators: kysely, enums, files, seed", + generators: "Built-in generation plugins: kysely, enums, files, seed", }; const validateName = (name: string | undefined) => { diff --git a/packages/create-sdk/src/index.ts b/packages/create-sdk/src/index.ts index 6e9fbacde..53d9e73cb 100644 --- a/packages/create-sdk/src/index.ts +++ b/packages/create-sdk/src/index.ts @@ -15,6 +15,7 @@ const main = async () => { const cmd = defineCommand({ name: packageJson.name ?? "create-sdk", description: packageJson.description, + // strip unknown keys args: z.object({ name: arg(z.string().optional(), { positional: true, diff --git a/packages/create-sdk/templates/executor/.gitignore b/packages/create-sdk/templates/executor/.gitignore index d04123787..f3b2377c8 100644 --- a/packages/create-sdk/templates/executor/.gitignore +++ b/packages/create-sdk/templates/executor/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/executor/.oxlintrc.json b/packages/create-sdk/templates/executor/.oxlintrc.json index 775d5a634..45319dcc8 100644 --- a/packages/create-sdk/templates/executor/.oxlintrc.json +++ b/packages/create-sdk/templates/executor/.oxlintrc.json @@ -13,7 +13,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "tailor-sdk/no-api-prefix-in-path-pattern": "warn", "tailor-sdk/no-unconditional-permit": "warn", diff --git a/packages/create-sdk/templates/executor/package.json b/packages/create-sdk/templates/executor/package.json index fe0c6bbbd..facb3b69d 100644 --- a/packages/create-sdk/templates/executor/package.json +++ b/packages/create-sdk/templates/executor/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "generate": "tailor-sdk generate", - "deploy": "tailor-sdk deploy", + "generate": "tailor generate", + "deploy": "tailor deploy", "test": "vitest --project unit", "test:unit": "vitest --project unit", "format": "oxfmt --write .", diff --git a/packages/create-sdk/templates/executor/src/db/auditLog.ts b/packages/create-sdk/templates/executor/src/db/auditLog.ts index 474175e33..0dca43a79 100644 --- a/packages/create-sdk/templates/executor/src/db/auditLog.ts +++ b/packages/create-sdk/templates/executor/src/db/auditLog.ts @@ -1,7 +1,7 @@ import { db } from "@tailor-platform/sdk"; export const auditLog = db - .type("AuditLog", "Records system events for auditing", { + .table("AuditLog", "Records system events for auditing", { action: db.string(), entityType: db.string(), entityId: db.uuid(), diff --git a/packages/create-sdk/templates/executor/src/db/notification.ts b/packages/create-sdk/templates/executor/src/db/notification.ts index 5df5b2398..17ca1474a 100644 --- a/packages/create-sdk/templates/executor/src/db/notification.ts +++ b/packages/create-sdk/templates/executor/src/db/notification.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { user } from "./user"; export const notification = db - .type("Notification", { + .table("Notification", { userId: db.uuid().relation({ type: "n-1", toward: { type: user }, diff --git a/packages/create-sdk/templates/executor/src/db/user.ts b/packages/create-sdk/templates/executor/src/db/user.ts index bed22445a..2b03277c8 100644 --- a/packages/create-sdk/templates/executor/src/db/user.ts +++ b/packages/create-sdk/templates/executor/src/db/user.ts @@ -1,7 +1,7 @@ import { db } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string().unique(), role: db.enum(["ADMIN", "MEMBER"]), diff --git a/packages/create-sdk/templates/executor/src/generated/db.ts b/packages/create-sdk/templates/executor/src/generated/db.ts index 1bdcc0d1f..c50b7fcbe 100644 --- a/packages/create-sdk/templates/executor/src/generated/db.ts +++ b/packages/create-sdk/templates/executor/src/generated/db.ts @@ -20,7 +20,7 @@ export interface Namespace { entityId: string; message: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Notification: { @@ -30,7 +30,7 @@ export interface Namespace { body: string; isRead: boolean; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -39,7 +39,7 @@ export interface Namespace { email: string; role: "ADMIN" | "MEMBER"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/executor/tailor.d.ts b/packages/create-sdk/templates/executor/tailor.d.ts index 84fb93e2d..c01151438 100644 --- a/packages/create-sdk/templates/executor/tailor.d.ts +++ b/packages/create-sdk/templates/executor/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: string; } interface AttributeList { diff --git a/packages/create-sdk/templates/generators/.gitignore b/packages/create-sdk/templates/generators/.gitignore index d04123787..f3b2377c8 100644 --- a/packages/create-sdk/templates/generators/.gitignore +++ b/packages/create-sdk/templates/generators/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/generators/.oxlintrc.json b/packages/create-sdk/templates/generators/.oxlintrc.json index f5d31a75a..79725f01a 100644 --- a/packages/create-sdk/templates/generators/.oxlintrc.json +++ b/packages/create-sdk/templates/generators/.oxlintrc.json @@ -13,7 +13,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "src/seed/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "src/seed/", "tailor.d.ts"], "rules": { "tailor-sdk/no-api-prefix-in-path-pattern": "warn", "tailor-sdk/no-unconditional-permit": "warn", diff --git a/packages/create-sdk/templates/generators/package.json b/packages/create-sdk/templates/generators/package.json index 75deefd8e..e7db5d518 100644 --- a/packages/create-sdk/templates/generators/package.json +++ b/packages/create-sdk/templates/generators/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "generate": "tailor-sdk generate", - "deploy": "tailor-sdk deploy", + "generate": "tailor generate", + "deploy": "tailor deploy", "test": "vitest --project unit", "test:unit": "vitest --project unit", "format": "oxfmt --write .", diff --git a/packages/create-sdk/templates/generators/src/db/category.ts b/packages/create-sdk/templates/generators/src/db/category.ts index e37d91bad..5c7fbef5d 100644 --- a/packages/create-sdk/templates/generators/src/db/category.ts +++ b/packages/create-sdk/templates/generators/src/db/category.ts @@ -1,7 +1,7 @@ import { db } from "@tailor-platform/sdk"; export const category = db - .type("Category", { + .table("Category", { name: db.string(), slug: db.string().unique(), parentCategoryId: db.uuid({ optional: true }).relation({ diff --git a/packages/create-sdk/templates/generators/src/db/order.ts b/packages/create-sdk/templates/generators/src/db/order.ts index c01b0fd7f..36f0a8b05 100644 --- a/packages/create-sdk/templates/generators/src/db/order.ts +++ b/packages/create-sdk/templates/generators/src/db/order.ts @@ -3,7 +3,7 @@ import { product } from "./product"; import { user } from "./user"; export const order = db - .type("Order", { + .table("Order", { productId: db.uuid().relation({ type: "n-1", toward: { type: product }, diff --git a/packages/create-sdk/templates/generators/src/db/product.ts b/packages/create-sdk/templates/generators/src/db/product.ts index bfb0a44c2..81a50a080 100644 --- a/packages/create-sdk/templates/generators/src/db/product.ts +++ b/packages/create-sdk/templates/generators/src/db/product.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { category } from "./category"; export const product = db - .type("Product", { + .table("Product", { name: db.string(), description: db.string({ optional: true }), price: db.float(), diff --git a/packages/create-sdk/templates/generators/src/db/user.ts b/packages/create-sdk/templates/generators/src/db/user.ts index d4d54e9b5..82e6ead69 100644 --- a/packages/create-sdk/templates/generators/src/db/user.ts +++ b/packages/create-sdk/templates/generators/src/db/user.ts @@ -1,7 +1,7 @@ import { db } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string().unique(), role: db.enum([ diff --git a/packages/create-sdk/templates/generators/src/generated/db.ts b/packages/create-sdk/templates/generators/src/generated/db.ts index 0da558bfe..a9b7441d5 100644 --- a/packages/create-sdk/templates/generators/src/generated/db.ts +++ b/packages/create-sdk/templates/generators/src/generated/db.ts @@ -28,7 +28,7 @@ export interface Namespace { totalPrice: number; status: "PENDING" | "CONFIRMED" | "SHIPPED" | "DELIVERED" | "CANCELLED"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Product: { @@ -39,7 +39,7 @@ export interface Namespace { status: "DRAFT" | "ACTIVE" | "DISCONTINUED"; categoryId: string | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -48,7 +48,7 @@ export interface Namespace { email: string; role: "ADMIN" | "MEMBER" | "VIEWER"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/generators/src/generated/files.ts b/packages/create-sdk/templates/generators/src/generated/files.ts index e7498a317..d27231a13 100644 --- a/packages/create-sdk/templates/generators/src/generated/files.ts +++ b/packages/create-sdk/templates/generators/src/generated/files.ts @@ -1,9 +1,9 @@ -import * as file from "@tailor-platform/sdk/runtime/file"; +import { file } from "@tailor-platform/sdk/runtime/file"; import type { FileUploadOptions, FileUploadResponse, FileMetadata, - FileStreamIterator, + FileDownloadStreamResponse, } from "@tailor-platform/sdk/runtime/file"; export interface TypeWithFiles { @@ -50,10 +50,10 @@ export async function getFileMetadata( return await file.getMetadata(namespaces[type], type, field, recordId); } -export async function openFileDownloadStream( +export async function downloadFileStream( type: T, field: TypeWithFiles[T]["fields"], recordId: string, -): Promise { - return await file.openDownloadStream(namespaces[type], type, field, recordId); +): Promise { + return await file.downloadStream(namespaces[type], type, field, recordId); } diff --git a/packages/create-sdk/templates/generators/src/resolver/getProduct.test.ts b/packages/create-sdk/templates/generators/src/resolver/getProduct.test.ts index 8042a07e7..03eca2686 100644 --- a/packages/create-sdk/templates/generators/src/resolver/getProduct.test.ts +++ b/packages/create-sdk/templates/generators/src/resolver/getProduct.test.ts @@ -1,4 +1,3 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { mockTailordb } from "@tailor-platform/sdk/vitest"; import { describe, expect, test } from "vitest"; import resolver from "./getProduct"; @@ -24,7 +23,8 @@ describe("getProduct resolver", () => { const result = await resolver.body({ input: { productId: "product-1" }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); @@ -54,7 +54,8 @@ describe("getProduct resolver", () => { const result = await resolver.body({ input: { productId: "product-2" }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); diff --git a/packages/create-sdk/templates/generators/src/seed/data/Order.schema.ts b/packages/create-sdk/templates/generators/src/seed/data/Order.schema.ts index dffeb95f3..c0a3accd4 100644 --- a/packages/create-sdk/templates/generators/src/seed/data/Order.schema.ts +++ b/packages/create-sdk/templates/generators/src/seed/data/Order.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { order } from "../../db/order"; const schemaType = t.object({ - ...order.pickFields(["id","createdAt"], { optional: true }), - ...order.omitFields(["id","createdAt"]), + ...order.pickFields(["id"], { optional: true }), + ...order.omitFields(["id"]), }); const hook = createTailorDBHook(order); diff --git a/packages/create-sdk/templates/generators/src/seed/data/Product.schema.ts b/packages/create-sdk/templates/generators/src/seed/data/Product.schema.ts index 2bf00829c..dbb32664a 100644 --- a/packages/create-sdk/templates/generators/src/seed/data/Product.schema.ts +++ b/packages/create-sdk/templates/generators/src/seed/data/Product.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { product } from "../../db/product"; const schemaType = t.object({ - ...product.pickFields(["id","createdAt"], { optional: true }), - ...product.omitFields(["id","createdAt"]), + ...product.pickFields(["id"], { optional: true }), + ...product.omitFields(["id"]), }); const hook = createTailorDBHook(product); diff --git a/packages/create-sdk/templates/generators/src/seed/data/User.schema.ts b/packages/create-sdk/templates/generators/src/seed/data/User.schema.ts index 2cbbdf2c5..9feda335c 100644 --- a/packages/create-sdk/templates/generators/src/seed/data/User.schema.ts +++ b/packages/create-sdk/templates/generators/src/seed/data/User.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { user } from "../../db/user"; const schemaType = t.object({ - ...user.pickFields(["id","createdAt"], { optional: true }), - ...user.omitFields(["id","createdAt"]), + ...user.pickFields(["id"], { optional: true }), + ...user.omitFields(["id"]), }); const hook = createTailorDBHook(user); diff --git a/packages/create-sdk/templates/generators/src/seed/exec.mjs b/packages/create-sdk/templates/generators/src/seed/exec.mjs index 865ed0666..0097ec9d9 100644 --- a/packages/create-sdk/templates/generators/src/seed/exec.mjs +++ b/packages/create-sdk/templates/generators/src/seed/exec.mjs @@ -388,7 +388,7 @@ const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) workspaceId, name: `seed-${namespace}.ts`, code: bundled.bundledCode, - arg: JSON.stringify({ data: chunk.data, order: chunk.order, selfRefTypes }), + arg: { data: chunk.data, order: chunk.order, selfRefTypes }, invoker: { namespace: authNamespace, machineUserName, diff --git a/packages/create-sdk/templates/generators/tailor.d.ts b/packages/create-sdk/templates/generators/tailor.d.ts index 84fb93e2d..c01151438 100644 --- a/packages/create-sdk/templates/generators/tailor.d.ts +++ b/packages/create-sdk/templates/generators/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: string; } interface AttributeList { diff --git a/packages/create-sdk/templates/hello-world/.gitignore b/packages/create-sdk/templates/hello-world/.gitignore index d04123787..f3b2377c8 100644 --- a/packages/create-sdk/templates/hello-world/.gitignore +++ b/packages/create-sdk/templates/hello-world/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/hello-world/.oxlintrc.json b/packages/create-sdk/templates/hello-world/.oxlintrc.json index f332ae1e7..f05451042 100644 --- a/packages/create-sdk/templates/hello-world/.oxlintrc.json +++ b/packages/create-sdk/templates/hello-world/.oxlintrc.json @@ -13,7 +13,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "tailor.d.ts"], "rules": { "tailor-sdk/no-api-prefix-in-path-pattern": "warn", "tailor-sdk/no-unconditional-permit": "warn", diff --git a/packages/create-sdk/templates/hello-world/README.md b/packages/create-sdk/templates/hello-world/README.md index df894c082..7b1ffca1a 100644 --- a/packages/create-sdk/templates/hello-world/README.md +++ b/packages/create-sdk/templates/hello-world/README.md @@ -9,12 +9,12 @@ This project was bootstrapped with [Create Tailor Platform SDK](https://www.npmj 1. Create a new workspace: ```bash -npx tailor-sdk login -npx tailor-sdk workspace create --name --region -npx tailor-sdk workspace list -# For yarn: yarn tailor-sdk -# For pnpm: pnpm tailor-sdk -# For bun: bun tailor-sdk +npx tailor login +npx tailor workspace create --name --region +npx tailor workspace list +# For yarn: yarn tailor +# For pnpm: pnpm tailor +# For bun: bun tailor # OR # Create a new workspace using Tailor Platform Console diff --git a/packages/create-sdk/templates/hello-world/package.json b/packages/create-sdk/templates/hello-world/package.json index ca1f3c151..c6d1efb9f 100644 --- a/packages/create-sdk/templates/hello-world/package.json +++ b/packages/create-sdk/templates/hello-world/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "deploy": "tailor-sdk deploy", - "generate": "tailor-sdk generate", + "deploy": "tailor deploy", + "generate": "tailor generate", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint --type-aware .", diff --git a/packages/create-sdk/templates/hello-world/src/db/user.ts b/packages/create-sdk/templates/hello-world/src/db/user.ts index fc4c0ffa1..0916904d5 100644 --- a/packages/create-sdk/templates/hello-world/src/db/user.ts +++ b/packages/create-sdk/templates/hello-world/src/db/user.ts @@ -5,7 +5,7 @@ import { } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string().description("Name of the user"), email: db.string().unique().description("Email address of the user"), role: db.enum(["MANAGER", "STAFF"]), diff --git a/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts b/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts index c42a19651..67fefdd51 100644 --- a/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts +++ b/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts @@ -19,7 +19,7 @@ export interface Namespace { email: string; role: "MANAGER" | "STAFF"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/hello-world/tailor.d.ts b/packages/create-sdk/templates/hello-world/tailor.d.ts index 4f350ad00..60ce67701 100644 --- a/packages/create-sdk/templates/hello-world/tailor.d.ts +++ b/packages/create-sdk/templates/hello-world/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap {} + interface Attributes {} interface AttributeList { __tuple?: []; } diff --git a/packages/create-sdk/templates/inventory-management/.gitignore b/packages/create-sdk/templates/inventory-management/.gitignore index d04123787..f3b2377c8 100644 --- a/packages/create-sdk/templates/inventory-management/.gitignore +++ b/packages/create-sdk/templates/inventory-management/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/inventory-management/.oxlintrc.json b/packages/create-sdk/templates/inventory-management/.oxlintrc.json index c98255b00..3bcc93d7e 100644 --- a/packages/create-sdk/templates/inventory-management/.oxlintrc.json +++ b/packages/create-sdk/templates/inventory-management/.oxlintrc.json @@ -13,7 +13,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "tailor-sdk/no-api-prefix-in-path-pattern": "warn", "tailor-sdk/no-unconditional-permit": "warn", diff --git a/packages/create-sdk/templates/inventory-management/README.md b/packages/create-sdk/templates/inventory-management/README.md index c129f1ab5..8f86cd1d5 100644 --- a/packages/create-sdk/templates/inventory-management/README.md +++ b/packages/create-sdk/templates/inventory-management/README.md @@ -9,12 +9,12 @@ This project was bootstrapped with [Create Tailor Platform SDK](https://www.npmj 1. Create a new workspace: ```bash -npx tailor-sdk login -npx tailor-sdk workspace create --name --region -npx tailor-sdk workspace list -# For yarn: yarn tailor-sdk -# For pnpm: pnpm tailor-sdk -# For bun: bun tailor-sdk +npx tailor login +npx tailor workspace create --name --region +npx tailor workspace list +# For yarn: yarn tailor +# For pnpm: pnpm tailor +# For bun: bun tailor # OR # Create a new workspace using Tailor Platform Console @@ -36,9 +36,9 @@ npm run deploy -- --workspace-id ```bash # Get Manager's token -npx tailor-sdk machineuser token manager --workspace-id +npx tailor machineuser token manager --workspace-id # Get Staff's token -npx tailor-sdk machineuser token staff --workspace-id +npx tailor machineuser token staff --workspace-id ``` ## Features diff --git a/packages/create-sdk/templates/inventory-management/package.json b/packages/create-sdk/templates/inventory-management/package.json index fee9d1b5a..53400fa60 100644 --- a/packages/create-sdk/templates/inventory-management/package.json +++ b/packages/create-sdk/templates/inventory-management/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "deploy": "tailor-sdk deploy", - "generate": "tailor-sdk generate", + "deploy": "tailor deploy", + "generate": "tailor generate", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint --type-aware .", diff --git a/packages/create-sdk/templates/inventory-management/src/db/category.ts b/packages/create-sdk/templates/inventory-management/src/db/category.ts index 66286d51c..cd641bd95 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/category.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/category.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { gqlPermissionManager, permissionManager } from "./common/permission"; export const category = db - .type("Category", { + .table("Category", { name: db.string().description("Name of the category").unique(), description: db.string({ optional: true }).description("Description of the category"), ...db.fields.timestamps(), diff --git a/packages/create-sdk/templates/inventory-management/src/db/contact.ts b/packages/create-sdk/templates/inventory-management/src/db/contact.ts index f6df8e5b0..5c04231b3 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/contact.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/contact.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { gqlPermissionManager, permissionManager } from "./common/permission"; export const contact = db - .type("Contact", { + .table("Contact", { name: db.string().description("Name of the contact"), email: db.string().unique().description("Email address of the contact"), phone: db.string({ optional: true }).description("Phone number of the contact"), diff --git a/packages/create-sdk/templates/inventory-management/src/db/inventory.ts b/packages/create-sdk/templates/inventory-management/src/db/inventory.ts index 6deb5d780..04aa1ab4b 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/inventory.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/inventory.ts @@ -3,7 +3,7 @@ import { gqlPermissionLoggedIn, permissionLoggedIn } from "./common/permission"; import { product } from "./product"; export const inventory = db - .type("Inventory", { + .table("Inventory", { productId: db .uuid() .description("ID of the product") @@ -11,7 +11,7 @@ export const inventory = db quantity: db .int() .description("Quantity of the product in inventory") - .validate(({ value }) => value >= 0), + .validate(({ value }) => (value < 0 ? "Quantity must be non-negative" : undefined)), ...db.fields.timestamps(), }) .permission(permissionLoggedIn) diff --git a/packages/create-sdk/templates/inventory-management/src/db/notification.ts b/packages/create-sdk/templates/inventory-management/src/db/notification.ts index 12ead23d6..4383cd93c 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/notification.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/notification.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { loggedIn, managerRole, permissionManager } from "./common/permission"; export const notification = db - .type("Notification", { + .table("Notification", { message: db.string().description("Notification message"), ...db.fields.timestamps(), }) diff --git a/packages/create-sdk/templates/inventory-management/src/db/order.ts b/packages/create-sdk/templates/inventory-management/src/db/order.ts index 4131e281d..cd518df5b 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/order.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/order.ts @@ -3,7 +3,7 @@ import { contact } from "./contact"; import { gqlPermissionLoggedIn, permissionLoggedIn } from "./common/permission"; export const order = db - .type("Order", { + .table("Order", { name: db.string().description("Name of the order"), description: db.string({ optional: true }).description("Description of the order"), orderDate: db.datetime().description("Date of the order"), diff --git a/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts b/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts index 2fc8c572e..ad042a47c 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts @@ -4,7 +4,7 @@ import { product } from "./product"; import { gqlPermissionLoggedIn, permissionLoggedIn } from "./common/permission"; export const orderItem = db - .type("OrderItem", { + .table("OrderItem", { orderId: db .uuid() .description("ID of the order") @@ -16,19 +16,23 @@ export const orderItem = db quantity: db .int() .description("Quantity of the product") - .validate(({ value }) => value >= 0), + .validate(({ value }) => (value < 0 ? "Quantity must be non-negative" : undefined)), unitPrice: db .float() .description("Unit price of the product") - .validate(({ value }) => value >= 0), - totalPrice: db.float({ optional: true }).description("Total price of the order item"), + .validate(({ value }) => (value < 0 ? "Unit price must be non-negative" : undefined)), + totalPrice: db.float().default(0).description("Total price of the order item"), ...db.fields.timestamps(), }) .hooks({ - totalPrice: { - create: ({ data }) => (data?.quantity ?? 0) * (data.unitPrice ?? 0), - update: ({ data }) => (data?.quantity ?? 0) * (data.unitPrice ?? 0), - }, + create: ({ input }) => ({ + totalPrice: (input?.quantity ?? 0) * (input?.unitPrice ?? 0), + }), + update: ({ input, oldRecord }) => ({ + totalPrice: + (input?.quantity ?? oldRecord?.quantity ?? 0) * + (input?.unitPrice ?? oldRecord?.unitPrice ?? 0), + }), }) .permission(permissionLoggedIn) .gqlPermission(gqlPermissionLoggedIn); diff --git a/packages/create-sdk/templates/inventory-management/src/db/product.ts b/packages/create-sdk/templates/inventory-management/src/db/product.ts index d7c980797..bb60e1e4d 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/product.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/product.ts @@ -3,7 +3,7 @@ import { category } from "./category"; import { gqlPermissionManager, permissionManager } from "./common/permission"; export const product = db - .type("Product", { + .table("Product", { name: db.string().description("Name of the product"), description: db.string({ optional: true }).description("Description of the product"), categoryId: db diff --git a/packages/create-sdk/templates/inventory-management/src/db/user.ts b/packages/create-sdk/templates/inventory-management/src/db/user.ts index 0ca4c5bbe..5a83c5b77 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/user.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/user.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { gqlPermissionManager, permissionManager } from "./common/permission"; export const user = db - .type("User", { + .table("User", { name: db.string().description("Name of the user"), email: db.string().unique().description("Email address of the user"), role: db.enum(["MANAGER", "STAFF"]), diff --git a/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts b/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts index 001ea023e..7d3752141 100644 --- a/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts +++ b/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts @@ -18,7 +18,7 @@ export interface Namespace { name: string; description: string | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Contact: { @@ -28,7 +28,7 @@ export interface Namespace { phone: string | null; address: string | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Inventory: { @@ -36,14 +36,14 @@ export interface Namespace { productId: string; quantity: number; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Notification: { id: Generated; message: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Order: { @@ -54,7 +54,7 @@ export interface Namespace { orderType: "PURCHASE" | "SALES"; contactId: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } OrderItem: { @@ -63,9 +63,9 @@ export interface Namespace { productId: string; quantity: number; unitPrice: number; - totalPrice: Generated; + totalPrice: Generated; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Product: { @@ -74,7 +74,7 @@ export interface Namespace { description: string | null; categoryId: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -83,7 +83,7 @@ export interface Namespace { email: string; role: "MANAGER" | "STAFF"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts b/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts index ee1f332a7..6565a79a0 100644 --- a/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts +++ b/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts @@ -4,8 +4,10 @@ import { orderItem } from "../db/orderItem"; import { type DB, getDB } from "../generated/kysely-tailordb"; const input = { - order: t.object(order.omitFields(["id", "createdAt"])), - items: t.object(orderItem.omitFields(["id", "createdAt"]), { array: true }), + order: t.object(order.omitFields(["id", "createdAt", "updatedAt"])), + items: t.object(orderItem.omitFields(["id", "createdAt", "updatedAt", "totalPrice"]), { + array: true, + }), }; interface Input { order: t.infer; diff --git a/packages/create-sdk/templates/inventory-management/tailor.d.ts b/packages/create-sdk/templates/inventory-management/tailor.d.ts index f27325cbf..07de61329 100644 --- a/packages/create-sdk/templates/inventory-management/tailor.d.ts +++ b/packages/create-sdk/templates/inventory-management/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: "MANAGER" | "STAFF"; } interface AttributeList { diff --git a/packages/create-sdk/templates/multi-application/.gitignore b/packages/create-sdk/templates/multi-application/.gitignore index d04123787..f3b2377c8 100644 --- a/packages/create-sdk/templates/multi-application/.gitignore +++ b/packages/create-sdk/templates/multi-application/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/multi-application/.oxlintrc.json b/packages/create-sdk/templates/multi-application/.oxlintrc.json index f332ae1e7..f05451042 100644 --- a/packages/create-sdk/templates/multi-application/.oxlintrc.json +++ b/packages/create-sdk/templates/multi-application/.oxlintrc.json @@ -13,7 +13,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "tailor.d.ts"], "rules": { "tailor-sdk/no-api-prefix-in-path-pattern": "warn", "tailor-sdk/no-unconditional-permit": "warn", diff --git a/packages/create-sdk/templates/multi-application/README.md b/packages/create-sdk/templates/multi-application/README.md index 99b14121a..fa1a56462 100644 --- a/packages/create-sdk/templates/multi-application/README.md +++ b/packages/create-sdk/templates/multi-application/README.md @@ -16,8 +16,8 @@ This project contains two applications: `user` and `admin`. 1. Create a new workspace: ```bash -npx tailor-sdk login -npx tailor-sdk workspace create --name --region +npx tailor login +npx tailor workspace create --name --region ``` 2. Deploy the project: diff --git a/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts b/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts index b3f5997c3..047f39a86 100644 --- a/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts +++ b/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts @@ -5,10 +5,10 @@ import { } from "@tailor-platform/sdk"; export const adminNote = db - .type("AdminNote", { + .table("AdminNote", { title: db.string(), content: db.string(), - authorId: db.uuid().hooks({ create: ({ user }) => user.id }), + authorId: db.uuid().hooks({ create: ({ invoker }) => invoker?.id ?? crypto.randomUUID() }), ...db.fields.timestamps(), }) // NOTE: This permits all operations for simplicity. diff --git a/packages/create-sdk/templates/multi-application/apps/user/db/user.ts b/packages/create-sdk/templates/multi-application/apps/user/db/user.ts index a070dc24a..2e4acc659 100644 --- a/packages/create-sdk/templates/multi-application/apps/user/db/user.ts +++ b/packages/create-sdk/templates/multi-application/apps/user/db/user.ts @@ -5,7 +5,7 @@ import { } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string().unique(), role: db.enum(["USER", "ADMIN"]), ...db.fields.timestamps(), diff --git a/packages/create-sdk/templates/multi-application/package.json b/packages/create-sdk/templates/multi-application/package.json index 8bd394c48..4dbed962a 100644 --- a/packages/create-sdk/templates/multi-application/package.json +++ b/packages/create-sdk/templates/multi-application/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "deploy": "pnpm run deploy:user && pnpm run deploy:admin", - "deploy:user": "tailor-sdk deploy -c apps/user/tailor.config.ts", - "deploy:admin": "tailor-sdk deploy -c apps/admin/tailor.config.ts", + "deploy:user": "tailor deploy -c apps/user/tailor.config.ts", + "deploy:admin": "tailor deploy -c apps/admin/tailor.config.ts", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint --type-aware .", diff --git a/packages/create-sdk/templates/resolver/.gitignore b/packages/create-sdk/templates/resolver/.gitignore index d04123787..f3b2377c8 100644 --- a/packages/create-sdk/templates/resolver/.gitignore +++ b/packages/create-sdk/templates/resolver/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/resolver/.oxlintrc.json b/packages/create-sdk/templates/resolver/.oxlintrc.json index 775d5a634..45319dcc8 100644 --- a/packages/create-sdk/templates/resolver/.oxlintrc.json +++ b/packages/create-sdk/templates/resolver/.oxlintrc.json @@ -13,7 +13,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "tailor-sdk/no-api-prefix-in-path-pattern": "warn", "tailor-sdk/no-unconditional-permit": "warn", diff --git a/packages/create-sdk/templates/resolver/README.md b/packages/create-sdk/templates/resolver/README.md index f0c531a61..f5c771dce 100644 --- a/packages/create-sdk/templates/resolver/README.md +++ b/packages/create-sdk/templates/resolver/README.md @@ -8,11 +8,11 @@ Demonstrates all resolver patterns with comprehensive testing approaches. - Database query resolver (Kysely with transactions) - Database mutation resolver (dependency injection pattern) - Environment variable access -- User context access +- Caller and invoker context access ## Testing Approaches -1. **Direct `body()` call** - Simple resolvers with `unauthenticatedTailorUser` +1. **Direct `body()` call** - Simple resolvers with explicit `caller` / `invoker` context values 2. **`tailor-runtime` environment + `mockTailordb`** - Database resolvers via `mockTailordb` from `@tailor-platform/sdk/vitest` (no `vi.stubGlobal` needed) 3. **Dependency injection** - Extract `DbOperations` interface for testability diff --git a/packages/create-sdk/templates/resolver/package.json b/packages/create-sdk/templates/resolver/package.json index d7be1a067..6583522fd 100644 --- a/packages/create-sdk/templates/resolver/package.json +++ b/packages/create-sdk/templates/resolver/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "generate": "tailor-sdk generate", - "deploy": "tailor-sdk deploy", + "generate": "tailor generate", + "deploy": "tailor deploy", "test": "vitest --project unit", "test:unit": "vitest --project unit", "format": "oxfmt --write .", diff --git a/packages/create-sdk/templates/resolver/src/db/user.ts b/packages/create-sdk/templates/resolver/src/db/user.ts index 87ee4bd63..436b16d2f 100644 --- a/packages/create-sdk/templates/resolver/src/db/user.ts +++ b/packages/create-sdk/templates/resolver/src/db/user.ts @@ -1,7 +1,7 @@ import { db } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string().unique(), age: db.int(), diff --git a/packages/create-sdk/templates/resolver/src/generated/db.ts b/packages/create-sdk/templates/resolver/src/generated/db.ts index e36ba8aa9..b892f0e12 100644 --- a/packages/create-sdk/templates/resolver/src/generated/db.ts +++ b/packages/create-sdk/templates/resolver/src/generated/db.ts @@ -19,7 +19,7 @@ export interface Namespace { email: string; age: number; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/resolver/src/resolver/add.test.ts b/packages/create-sdk/templates/resolver/src/resolver/add.test.ts index 23ee62307..373e38462 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/add.test.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/add.test.ts @@ -1,4 +1,3 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { describe, expect, test } from "vitest"; import resolver from "./add"; @@ -6,7 +5,8 @@ describe("add resolver", () => { test("adds two positive numbers", async () => { const result = await resolver.body({ input: { left: 1, right: 2 }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toBe(3); @@ -15,7 +15,8 @@ describe("add resolver", () => { test("handles negative numbers", async () => { const result = await resolver.body({ input: { left: -5, right: 3 }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toBe(-2); diff --git a/packages/create-sdk/templates/resolver/src/resolver/incrementUserAge.test.ts b/packages/create-sdk/templates/resolver/src/resolver/incrementUserAge.test.ts index c42bd7be3..97cb2a253 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/incrementUserAge.test.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/incrementUserAge.test.ts @@ -1,4 +1,3 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { mockTailordb } from "@tailor-platform/sdk/vitest"; import { describe, expect, test } from "vitest"; import resolver from "./incrementUserAge"; @@ -15,7 +14,8 @@ describe("incrementUserAge resolver", () => { const result = await resolver.body({ input: { email: "test@example.com" }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toEqual({ oldAge: 30, newAge: 31 }); @@ -32,7 +32,8 @@ describe("incrementUserAge resolver", () => { const result = resolver.body({ input: { email: "test@example.com" }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); await expect(result).rejects.toThrowError(/no result/i); diff --git a/packages/create-sdk/templates/resolver/src/resolver/showEnv.test.ts b/packages/create-sdk/templates/resolver/src/resolver/showEnv.test.ts index 1973897a9..19316895b 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/showEnv.test.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/showEnv.test.ts @@ -1,4 +1,3 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { describe, expect, test } from "vitest"; import resolver from "./showEnv"; @@ -6,7 +5,8 @@ describe("showEnv resolver", () => { test("returns environment variables", async () => { const result = await resolver.body({ input: undefined as never, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toEqual({ appName: "Resolver Template", version: 1 }); diff --git a/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.test.ts b/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.test.ts index 2a2f9739a..991ae9e4f 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.test.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.test.ts @@ -1,4 +1,4 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; +import type { TailorPrincipal } from "@tailor-platform/sdk"; import { describe, expect, test } from "vitest"; import resolver from "./showUserInfo"; @@ -6,26 +6,29 @@ describe("showUserInfo resolver", () => { test("returns default user info", async () => { const result = await resolver.body({ input: undefined as never, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toEqual({ - userId: unauthenticatedTailorUser.id, - userType: unauthenticatedTailorUser.type, - workspaceId: unauthenticatedTailorUser.workspaceId, + userId: "anonymous", + userType: "anonymous", + workspaceId: "", }); }); test("returns custom user info", async () => { - const customUser = { - ...unauthenticatedTailorUser, + const customCaller = { id: "user-123", type: "machine_user" as const, workspaceId: "ws-456", - }; + attributes: { role: "admin" }, + attributeList: [], + } satisfies TailorPrincipal; const result = await resolver.body({ input: undefined as never, - user: customUser, + caller: customCaller, + invoker: customCaller, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toEqual({ diff --git a/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.ts b/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.ts index b848df640..6e33d0f8a 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.ts @@ -6,9 +6,9 @@ const resolver = createResolver({ operation: "query", body: (context) => { return { - userId: context.user.id, - userType: context.user.type, - workspaceId: context.user.workspaceId, + userId: context.caller?.id ?? "anonymous", + userType: context.caller?.type ?? "anonymous", + workspaceId: context.caller?.workspaceId ?? "", }; }, output: t.object({ diff --git a/packages/create-sdk/templates/resolver/src/resolver/upsertUsers.test.ts b/packages/create-sdk/templates/resolver/src/resolver/upsertUsers.test.ts index b953f1a33..a09b1fbd8 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/upsertUsers.test.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/upsertUsers.test.ts @@ -1,4 +1,3 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { createKyselyMock } from "@tailor-platform/sdk/vitest"; import { describe, expect, test, vi } from "vitest"; import { getDB, type Namespace } from "../generated/db"; @@ -30,7 +29,8 @@ describe("upsertUsers resolver", () => { { name: "Existing", email: "exists@example.com", age: 41 }, ], }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); diff --git a/packages/create-sdk/templates/resolver/tailor.d.ts b/packages/create-sdk/templates/resolver/tailor.d.ts index 37b157d39..22fe5946b 100644 --- a/packages/create-sdk/templates/resolver/tailor.d.ts +++ b/packages/create-sdk/templates/resolver/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: string; } interface AttributeList { diff --git a/packages/create-sdk/templates/static-web-site/.gitignore b/packages/create-sdk/templates/static-web-site/.gitignore index d04123787..f3b2377c8 100644 --- a/packages/create-sdk/templates/static-web-site/.gitignore +++ b/packages/create-sdk/templates/static-web-site/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/static-web-site/.oxlintrc.json b/packages/create-sdk/templates/static-web-site/.oxlintrc.json index 775d5a634..45319dcc8 100644 --- a/packages/create-sdk/templates/static-web-site/.oxlintrc.json +++ b/packages/create-sdk/templates/static-web-site/.oxlintrc.json @@ -13,7 +13,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "tailor-sdk/no-api-prefix-in-path-pattern": "warn", "tailor-sdk/no-unconditional-permit": "warn", diff --git a/packages/create-sdk/templates/static-web-site/package.json b/packages/create-sdk/templates/static-web-site/package.json index 87217f9f7..351254a99 100644 --- a/packages/create-sdk/templates/static-web-site/package.json +++ b/packages/create-sdk/templates/static-web-site/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "deploy": "tailor-sdk deploy", - "generate": "tailor-sdk generate", + "deploy": "tailor deploy", + "generate": "tailor generate", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint --type-aware .", diff --git a/packages/create-sdk/templates/static-web-site/src/db/user.ts b/packages/create-sdk/templates/static-web-site/src/db/user.ts index bed22445a..2b03277c8 100644 --- a/packages/create-sdk/templates/static-web-site/src/db/user.ts +++ b/packages/create-sdk/templates/static-web-site/src/db/user.ts @@ -1,7 +1,7 @@ import { db } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string().unique(), role: db.enum(["ADMIN", "MEMBER"]), diff --git a/packages/create-sdk/templates/static-web-site/tailor.d.ts b/packages/create-sdk/templates/static-web-site/tailor.d.ts index 58cba9bc5..863e217f3 100644 --- a/packages/create-sdk/templates/static-web-site/tailor.d.ts +++ b/packages/create-sdk/templates/static-web-site/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: "ADMIN" | "MEMBER"; } interface AttributeList { diff --git a/packages/create-sdk/templates/tailordb/.gitignore b/packages/create-sdk/templates/tailordb/.gitignore index d04123787..f3b2377c8 100644 --- a/packages/create-sdk/templates/tailordb/.gitignore +++ b/packages/create-sdk/templates/tailordb/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/tailordb/.oxlintrc.json b/packages/create-sdk/templates/tailordb/.oxlintrc.json index 775d5a634..45319dcc8 100644 --- a/packages/create-sdk/templates/tailordb/.oxlintrc.json +++ b/packages/create-sdk/templates/tailordb/.oxlintrc.json @@ -13,7 +13,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "tailor-sdk/no-api-prefix-in-path-pattern": "warn", "tailor-sdk/no-unconditional-permit": "warn", diff --git a/packages/create-sdk/templates/tailordb/README.md b/packages/create-sdk/templates/tailordb/README.md index 35649defc..b936f3fd4 100644 --- a/packages/create-sdk/templates/tailordb/README.md +++ b/packages/create-sdk/templates/tailordb/README.md @@ -1,6 +1,6 @@ # TailorDB Template -Comprehensive TailorDB type definitions demonstrating all features of `db.type()`. +Comprehensive TailorDB type definitions demonstrating all features of `db.table()`. ## Features diff --git a/packages/create-sdk/templates/tailordb/package.json b/packages/create-sdk/templates/tailordb/package.json index 90ab3c4b3..4d7c1b0f3 100644 --- a/packages/create-sdk/templates/tailordb/package.json +++ b/packages/create-sdk/templates/tailordb/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "generate": "tailor-sdk generate", - "deploy": "tailor-sdk deploy", + "generate": "tailor generate", + "deploy": "tailor deploy", "test": "vitest --project unit", "test:unit": "vitest --project unit", "format": "oxfmt --write .", diff --git a/packages/create-sdk/templates/tailordb/src/db/category.ts b/packages/create-sdk/templates/tailordb/src/db/category.ts index 37e97c585..280288a69 100644 --- a/packages/create-sdk/templates/tailordb/src/db/category.ts +++ b/packages/create-sdk/templates/tailordb/src/db/category.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { allPermission, allGqlPermission } from "./permission"; export const category = db - .type("Category", "Task category with hierarchical structure", { + .table("Category", "Task category with hierarchical structure", { name: db.string(), description: db.string({ optional: true }), parentCategoryId: db.uuid({ optional: true }).relation({ diff --git a/packages/create-sdk/templates/tailordb/src/db/comment.ts b/packages/create-sdk/templates/tailordb/src/db/comment.ts index 5f8f18067..318aae125 100644 --- a/packages/create-sdk/templates/tailordb/src/db/comment.ts +++ b/packages/create-sdk/templates/tailordb/src/db/comment.ts @@ -4,8 +4,10 @@ import { task } from "./task"; import { user } from "./user"; export const comment = db - .type("Comment", "A comment on a task", { - body: db.string().validate([({ value }) => value.length >= 1, "Comment must not be empty"]), + .table("Comment", "A comment on a task", { + body: db + .string() + .validate(({ value }) => (value.length < 1 ? "Comment must not be empty" : undefined)), taskId: db.uuid().relation({ type: "n-1", toward: { type: task }, diff --git a/packages/create-sdk/templates/tailordb/src/db/task.ts b/packages/create-sdk/templates/tailordb/src/db/task.ts index 4e4a56143..489db585a 100644 --- a/packages/create-sdk/templates/tailordb/src/db/task.ts +++ b/packages/create-sdk/templates/tailordb/src/db/task.ts @@ -4,13 +4,11 @@ import { rolePermission, roleGqlPermission } from "./permission"; import { user } from "./user"; export const task = db - .type("Task", "A task with comprehensive features", { - title: db - .string() - .validate( - [({ value }) => value.length >= 3, "Title must be at least 3 characters"], - [({ value }) => value.length <= 200, "Title must be at most 200 characters"], - ), + .table("Task", "A task with comprehensive features", { + title: db.string().validate( + ({ value }) => (value.length >= 3 ? undefined : "Title must be at least 3 characters"), + ({ value }) => (value.length <= 200 ? undefined : "Title must be at most 200 characters"), + ), description: db.string({ optional: true }), status: db.enum([ { value: "TODO", description: "Not started" }, @@ -18,12 +16,10 @@ export const task = db { value: "DONE", description: "Completed" }, { value: "CANCELLED", description: "No longer needed" }, ]), - priority: db - .int() - .validate( - [({ value }) => value >= 0, "Priority must be non-negative"], - [({ value }) => value <= 4, "Priority must be at most 4"], - ), + priority: db.int().validate( + ({ value }) => (value >= 0 ? undefined : "Priority must be non-negative"), + ({ value }) => (value <= 4 ? undefined : "Priority must be at most 4"), + ), dueDate: db.datetime({ optional: true }), assigneeId: db.uuid({ optional: true }).relation({ type: "n-1", @@ -33,26 +29,22 @@ export const task = db type: "n-1", toward: { type: category }, }), - isArchived: db.bool().description("Whether the task is archived"), + isArchived: db + .bool() + .description("Whether the task is archived") + .hooks({ + create: () => false, + }), ...db.fields.timestamps(), }) - .hooks({ - isArchived: { - create: () => false, - }, - }) .indexes( { fields: ["status", "priority"], unique: false }, { fields: ["assigneeId", "status"], unique: false, name: "task_assignee_status_idx" }, ) - .validate({ - status: [ - ({ value, data }) => { - const d = data as { dueDate: string | null }; - return !(value === "DONE" && d.dueDate === null); - }, - "Completed tasks must have a due date", - ], + .validate(({ newRecord }, issues) => { + if (newRecord.status === "DONE" && !newRecord.dueDate) { + issues("status", "Completed tasks must have a due date"); + } }) .permission(rolePermission) .gqlPermission(roleGqlPermission); diff --git a/packages/create-sdk/templates/tailordb/src/db/user.ts b/packages/create-sdk/templates/tailordb/src/db/user.ts index 0f459b6c3..741e8b7d5 100644 --- a/packages/create-sdk/templates/tailordb/src/db/user.ts +++ b/packages/create-sdk/templates/tailordb/src/db/user.ts @@ -2,7 +2,7 @@ import { db } from "@tailor-platform/sdk"; import { allPermission, allGqlPermission } from "./permission"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string().unique(), role: db.enum([ diff --git a/packages/create-sdk/templates/tailordb/src/generated/db.ts b/packages/create-sdk/templates/tailordb/src/generated/db.ts index f68627d27..46840cc83 100644 --- a/packages/create-sdk/templates/tailordb/src/generated/db.ts +++ b/packages/create-sdk/templates/tailordb/src/generated/db.ts @@ -32,7 +32,7 @@ export interface Namespace { isInternal: boolean; }>; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Task: { @@ -46,7 +46,7 @@ export interface Namespace { categoryId: string | null; isArchived: Generated; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -56,7 +56,7 @@ export interface Namespace { role: "ADMIN" | "MEMBER" | "VIEWER"; bio: string | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/tailordb/tailor.d.ts b/packages/create-sdk/templates/tailordb/tailor.d.ts index 445be30a3..d241e8f15 100644 --- a/packages/create-sdk/templates/tailordb/tailor.d.ts +++ b/packages/create-sdk/templates/tailordb/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: string; } interface AttributeList { diff --git a/packages/create-sdk/templates/workflow/.gitignore b/packages/create-sdk/templates/workflow/.gitignore index d04123787..f3b2377c8 100644 --- a/packages/create-sdk/templates/workflow/.gitignore +++ b/packages/create-sdk/templates/workflow/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/workflow/.oxlintrc.json b/packages/create-sdk/templates/workflow/.oxlintrc.json index 775d5a634..45319dcc8 100644 --- a/packages/create-sdk/templates/workflow/.oxlintrc.json +++ b/packages/create-sdk/templates/workflow/.oxlintrc.json @@ -13,7 +13,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "tailor-sdk/no-api-prefix-in-path-pattern": "warn", "tailor-sdk/no-unconditional-permit": "warn", diff --git a/packages/create-sdk/templates/workflow/README.md b/packages/create-sdk/templates/workflow/README.md index 8476d85fc..393a7b41b 100644 --- a/packages/create-sdk/templates/workflow/README.md +++ b/packages/create-sdk/templates/workflow/README.md @@ -1,13 +1,13 @@ # Workflow Template -Demonstrates workflow patterns with job chaining, trigger testing, and dependency injection. +Demonstrates workflow patterns with job chaining, start testing, and dependency injection. ## Features - Workflow with multiple jobs (`createWorkflow`, `createWorkflowJob`) -- Job chaining via `.trigger()` +- Job chaining via `.start()` - Database operations in workflow jobs (DI pattern) -- Integration testing with `workflow.mainJob.trigger()` +- Integration testing with `runWorkflowLocally()` ## Getting Started diff --git a/packages/create-sdk/templates/workflow/e2e/globalSetup.ts b/packages/create-sdk/templates/workflow/e2e/globalSetup.ts index 6af16a754..3ad71e969 100644 --- a/packages/create-sdk/templates/workflow/e2e/globalSetup.ts +++ b/packages/create-sdk/templates/workflow/e2e/globalSetup.ts @@ -55,7 +55,7 @@ export async function setup(project: TestProject): Promise { process.env.TAILOR_PLATFORM_WORKSPACE_ID = createdWorkspace.id; // This pipeline deploys a fresh app into a per-run workspace, so let the // SDK inject a missing app id even in CI (normally a hard error there). - process.env.TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION = "true"; + process.env.TAILOR_CI_ALLOW_ID_INJECTION = "true"; await deployApplication(); } diff --git a/packages/create-sdk/templates/workflow/e2e/workflow.test.ts b/packages/create-sdk/templates/workflow/e2e/workflow.test.ts index 2bc2adc36..67f3b7e15 100644 --- a/packages/create-sdk/templates/workflow/e2e/workflow.test.ts +++ b/packages/create-sdk/templates/workflow/e2e/workflow.test.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import { describe, expect, test } from "vitest"; import { startWorkflow } from "@tailor-platform/sdk/cli"; -import config from "../tailor.config"; import userProfileSyncWorkflow from "../src/workflow/sync-profile"; describe("workflow", () => { @@ -11,7 +10,7 @@ describe("workflow", () => { const { executionId, wait } = await startWorkflow({ workflow: userProfileSyncWorkflow, - authInvoker: config.auth.invoker("admin"), + invoker: "admin", arg: { name: "workflow-test-user", email: testEmail, diff --git a/packages/create-sdk/templates/workflow/package.json b/packages/create-sdk/templates/workflow/package.json index 31031b397..00e9ffe28 100644 --- a/packages/create-sdk/templates/workflow/package.json +++ b/packages/create-sdk/templates/workflow/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "generate": "tailor-sdk generate", - "deploy": "tailor-sdk deploy", + "generate": "tailor generate", + "deploy": "tailor deploy", "test": "vitest --project unit", "test:unit": "vitest --project unit", "test:e2e": "vitest --project e2e", diff --git a/packages/create-sdk/templates/workflow/src/db/order.ts b/packages/create-sdk/templates/workflow/src/db/order.ts index d163f811f..909bbf423 100644 --- a/packages/create-sdk/templates/workflow/src/db/order.ts +++ b/packages/create-sdk/templates/workflow/src/db/order.ts @@ -1,7 +1,7 @@ import { db } from "@tailor-platform/sdk"; export const order = db - .type("Order", { + .table("Order", { customerName: db.string(), amount: db.int(), status: db.enum(["PENDING", "PROCESSING", "COMPLETED", "FAILED"]), diff --git a/packages/create-sdk/templates/workflow/src/db/user.ts b/packages/create-sdk/templates/workflow/src/db/user.ts index 87ee4bd63..436b16d2f 100644 --- a/packages/create-sdk/templates/workflow/src/db/user.ts +++ b/packages/create-sdk/templates/workflow/src/db/user.ts @@ -1,7 +1,7 @@ import { db } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string().unique(), age: db.int(), diff --git a/packages/create-sdk/templates/workflow/src/generated/db.ts b/packages/create-sdk/templates/workflow/src/generated/db.ts index 51e0e14cb..bf14fa403 100644 --- a/packages/create-sdk/templates/workflow/src/generated/db.ts +++ b/packages/create-sdk/templates/workflow/src/generated/db.ts @@ -19,7 +19,7 @@ export interface Namespace { amount: number; status: "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { @@ -28,7 +28,7 @@ export interface Namespace { email: string; age: number; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/workflow/src/resolver/resolveApproval.test.ts b/packages/create-sdk/templates/workflow/src/resolver/resolveApproval.test.ts index 60c3c56c7..bd2f6a257 100644 --- a/packages/create-sdk/templates/workflow/src/resolver/resolveApproval.test.ts +++ b/packages/create-sdk/templates/workflow/src/resolver/resolveApproval.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "vitest"; import { mockWorkflow } from "@tailor-platform/sdk/vitest"; -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { approval } from "../workflow/approval"; import resolver from "./resolveApproval"; @@ -18,7 +17,8 @@ describe("resolveApproval resolver", () => { const result = await resolver.body({ input: { executionId: "exec-1", approved: true }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); @@ -36,7 +36,8 @@ describe("resolveApproval resolver", () => { const result = await resolver.body({ input: { executionId: "exec-2", approved: false }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); diff --git a/packages/create-sdk/templates/workflow/src/workflow/approval.test.ts b/packages/create-sdk/templates/workflow/src/workflow/approval.test.ts index 54a7e0ff6..79049ed26 100644 --- a/packages/create-sdk/templates/workflow/src/workflow/approval.test.ts +++ b/packages/create-sdk/templates/workflow/src/workflow/approval.test.ts @@ -8,7 +8,10 @@ describe("approval workflow", () => { const approvalMock = wf.waitPoint(approval); approvalMock.wait.mockResolvedValue({ approved: true }); - const result = await processWithApproval.body({ orderId: "order-1" }, { env: {} }); + const result = await processWithApproval.body( + { orderId: "order-1" }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ orderId: "order-1", status: "approved" }); expect(approvalMock.wait).toHaveBeenCalledWith({ @@ -21,7 +24,10 @@ describe("approval workflow", () => { using wf = mockWorkflow(); wf.waitPoint(approval).wait.mockResolvedValue({ approved: false }); - const result = await processWithApproval.body({ orderId: "order-2" }, { env: {} }); + const result = await processWithApproval.body( + { orderId: "order-2" }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ orderId: "order-2", status: "rejected" }); }); diff --git a/packages/create-sdk/templates/workflow/src/workflow/approval.ts b/packages/create-sdk/templates/workflow/src/workflow/approval.ts index 950450ed2..b8038baae 100644 --- a/packages/create-sdk/templates/workflow/src/workflow/approval.ts +++ b/packages/create-sdk/templates/workflow/src/workflow/approval.ts @@ -1,6 +1,6 @@ -import { createWorkflow, createWorkflowJob, defineWaitPoints } from "@tailor-platform/sdk"; +import { createWaitPoints, createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; -export const { approval } = defineWaitPoints((define) => ({ +export const { approval } = createWaitPoints((define) => ({ /** Approval for order processing */ approval: define<{ message: string; orderId: string }, { approved: boolean }>(), })); diff --git a/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts b/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts index db1a1bc4b..6d195a34f 100644 --- a/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts +++ b/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts @@ -1,3 +1,4 @@ +import { runWorkflowLocally } from "@tailor-platform/sdk/vitest"; import { describe, expect, test, vi } from "vitest"; import workflow, { fulfillOrder, @@ -9,18 +10,24 @@ import workflow, { describe("order fulfillment workflow", () => { describe("individual job tests with .body()", () => { test("validateOrder accepts valid order", () => { - const result = validateOrder.body({ orderId: "order-1", amount: 100 }, { env: {} }); + const result = validateOrder.body( + { orderId: "order-1", amount: 100 }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ valid: true, orderId: "order-1" }); }); test("validateOrder rejects zero amount", () => { - expect(() => validateOrder.body({ orderId: "order-1", amount: 0 }, { env: {} })).toThrow( - "Order amount must be positive", - ); + expect(() => + validateOrder.body({ orderId: "order-1", amount: 0 }, { env: {}, invoker: null }), + ).toThrow("Order amount must be positive"); }); test("processPayment returns transaction", () => { - const result = processPayment.body({ orderId: "order-1", amount: 100 }, { env: {} }); + const result = processPayment.body( + { orderId: "order-1", amount: 100 }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ transactionId: "txn-order-1", amount: 100, @@ -31,7 +38,7 @@ describe("order fulfillment workflow", () => { test("sendConfirmation returns confirmation", () => { const result = sendConfirmation.body( { orderId: "order-1", transactionId: "txn-1" }, - { env: {} }, + { env: {}, invoker: null }, ); expect(result).toEqual({ orderId: "order-1", @@ -41,34 +48,37 @@ describe("order fulfillment workflow", () => { }); }); - describe("orchestration tests with mocked triggers", () => { + describe("orchestration tests with mocked starts", () => { test("fulfillOrder chains all jobs", async () => { - using _validateSpy = vi.spyOn(validateOrder, "trigger").mockResolvedValue({ + using _validateSpy = vi.spyOn(validateOrder, "start").mockReturnValue({ valid: true, orderId: "order-1", }); - using _paymentSpy = vi.spyOn(processPayment, "trigger").mockResolvedValue({ + using _paymentSpy = vi.spyOn(processPayment, "start").mockReturnValue({ transactionId: "txn-order-1", amount: 100, status: "completed" as const, }); - using _confirmSpy = vi.spyOn(sendConfirmation, "trigger").mockResolvedValue({ + using _confirmSpy = vi.spyOn(sendConfirmation, "start").mockReturnValue({ orderId: "order-1", transactionId: "txn-order-1", confirmed: true, }); - const result = await fulfillOrder.body({ orderId: "order-1", amount: 100 }, { env: {} }); + const result = await fulfillOrder.body( + { orderId: "order-1", amount: 100 }, + { env: {}, invoker: null }, + ); - expect(validateOrder.trigger).toHaveBeenCalledWith({ + expect(validateOrder.start).toHaveBeenCalledWith({ orderId: "order-1", amount: 100, }); - expect(processPayment.trigger).toHaveBeenCalledWith({ + expect(processPayment.start).toHaveBeenCalledWith({ orderId: "order-1", amount: 100, }); - expect(sendConfirmation.trigger).toHaveBeenCalledWith({ + expect(sendConfirmation.start).toHaveBeenCalledWith({ orderId: "order-1", transactionId: "txn-order-1", }); @@ -81,22 +91,25 @@ describe("order fulfillment workflow", () => { }); test("workflow.mainJob.body() chains all jobs", async () => { - using _validateSpy = vi.spyOn(validateOrder, "trigger").mockResolvedValue({ + using _validateSpy = vi.spyOn(validateOrder, "start").mockReturnValue({ valid: true, orderId: "order-2", }); - using _paymentSpy = vi.spyOn(processPayment, "trigger").mockResolvedValue({ + using _paymentSpy = vi.spyOn(processPayment, "start").mockReturnValue({ transactionId: "txn-order-2", amount: 200, status: "completed" as const, }); - using _confirmSpy = vi.spyOn(sendConfirmation, "trigger").mockResolvedValue({ + using _confirmSpy = vi.spyOn(sendConfirmation, "start").mockReturnValue({ orderId: "order-2", transactionId: "txn-order-2", confirmed: true, }); - const result = await workflow.mainJob.body({ orderId: "order-2", amount: 200 }, { env: {} }); + const result = await workflow.mainJob.body( + { orderId: "order-2", amount: 200 }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ orderId: "order-2", @@ -107,9 +120,9 @@ describe("order fulfillment workflow", () => { }); }); - describe("integration tests with .trigger()", () => { - test("workflow.mainJob.trigger() executes all jobs", async () => { - const result = await workflow.mainJob.trigger({ + describe("integration tests with runWorkflowLocally()", () => { + test("runWorkflowLocally() executes all jobs", async () => { + const result = await runWorkflowLocally(workflow, { orderId: "order-3", amount: 300, }); diff --git a/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.ts b/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.ts index 163406811..ed053c80d 100644 --- a/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.ts +++ b/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.ts @@ -34,8 +34,8 @@ export const sendConfirmation = createWorkflowJob({ export const fulfillOrder = createWorkflowJob({ name: "fulfill-order", - body: async (input: { orderId: string; amount: number }) => { - const validation = await validateOrder.trigger({ + body: (input: { orderId: string; amount: number }) => { + const validation = validateOrder.start({ orderId: input.orderId, amount: input.amount, }); @@ -44,12 +44,12 @@ export const fulfillOrder = createWorkflowJob({ throw new Error("Order validation failed"); } - const payment = await processPayment.trigger({ + const payment = processPayment.start({ orderId: input.orderId, amount: input.amount, }); - const confirmation = await sendConfirmation.trigger({ + const confirmation = sendConfirmation.start({ orderId: input.orderId, transactionId: payment.transactionId, }); diff --git a/packages/create-sdk/templates/workflow/tailor.d.ts b/packages/create-sdk/templates/workflow/tailor.d.ts index 382290f49..762531a0c 100644 --- a/packages/create-sdk/templates/workflow/tailor.d.ts +++ b/packages/create-sdk/templates/workflow/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: string; } interface AttributeList { diff --git a/packages/create-sdk/templates/workflow/vitest.config.ts b/packages/create-sdk/templates/workflow/vitest.config.ts index cc5905a97..d57ae2c43 100644 --- a/packages/create-sdk/templates/workflow/vitest.config.ts +++ b/packages/create-sdk/templates/workflow/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: { label: "e2e", color: "green" }, include: ["e2e/**/*.test.ts"], globalSetup: "e2e/globalSetup.ts", + testTimeout: 60_000, }, }, ], diff --git a/packages/eslint-plugin-sdk/CHANGELOG.md b/packages/eslint-plugin-sdk/CHANGELOG.md new file mode 100644 index 000000000..1d7fec3c1 --- /dev/null +++ b/packages/eslint-plugin-sdk/CHANGELOG.md @@ -0,0 +1,7 @@ +# @tailor-platform/eslint-plugin-sdk + +## 0.1.0-next.0 + +### Minor Changes + +- [#1737](https://github.com/tailor-platform/sdk/pull/1737) [`e349b9e`](https://github.com/tailor-platform/sdk/commit/e349b9e3d9c61f324f21dea92dd08055493a2c6d) Thanks [@dqn](https://github.com/dqn)! - Add lint rules that flag the external /api prefix in HTTP adapter path patterns and permission settings that grant access unconditionally, and enable them in newly scaffolded projects. diff --git a/packages/eslint-plugin-sdk/package.json b/packages/eslint-plugin-sdk/package.json index 389bc607b..c88814df3 100644 --- a/packages/eslint-plugin-sdk/package.json +++ b/packages/eslint-plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@tailor-platform/eslint-plugin-sdk", - "version": "0.0.0", + "version": "0.1.0-next.0", "description": "Lint rules for Tailor Platform SDK applications", "license": "MIT", "repository": { diff --git a/packages/sdk-codemod/.oxlintrc.json b/packages/sdk-codemod/.oxlintrc.json index a9488014a..09c7307e2 100644 --- a/packages/sdk-codemod/.oxlintrc.json +++ b/packages/sdk-codemod/.oxlintrc.json @@ -28,7 +28,10 @@ "typescript/no-namespace": "error", "typescript/no-require-imports": "error", "typescript/no-unnecessary-type-constraint": "error", - "typescript/no-unsafe-function-type": "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": [ { @@ -56,5 +59,6 @@ "prefer-spread": "error" } } - ] + ], + "jsPlugins": ["eslint-plugin-zod", "../../scripts/oxlint/tailor-zod-plugin.cjs"] } diff --git a/packages/sdk-codemod/CHANGELOG.md b/packages/sdk-codemod/CHANGELOG.md index 6362cb90e..c17b9189c 100644 --- a/packages/sdk-codemod/CHANGELOG.md +++ b/packages/sdk-codemod/CHANGELOG.md @@ -1,5 +1,240 @@ # @tailor-platform/sdk-codemod +## 0.3.0-next.7 + +### Patch Changes + +- [#1787](https://github.com/tailor-platform/sdk/pull/1787) [`898d0b0`](https://github.com/tailor-platform/sdk/commit/898d0b0f809d15ea883a32a78a247eda4ca7caa7) Thanks [@toiroakr](https://github.com/toiroakr)! - Fix `tailor upgrade` reporting zero codemods across several v2 prerelease boundaries. `v2/db-type-to-table` and `v2/runtime-subpath-namespace` now trigger at `2.0.0-next.4` (where they actually shipped) instead of `2.0.0-next.3`, and `v2/forward-relation-name`, `v2/tailordb-validate-simplify`, and `v2/tailordb-hook-redesign` now trigger at `2.0.0-next.5` instead of `2.0.0-next.4`. + +- [#1787](https://github.com/tailor-platform/sdk/pull/1787) [`6653afd`](https://github.com/tailor-platform/sdk/commit/6653afd5a0be52f8c9a1dc6fa50e445f0f678dc0) Thanks [@toiroakr](https://github.com/toiroakr)! - Add a `V2_NEXT_PENDING` placeholder `prereleaseUntil` for codemods whose exact `2.0.0-next.N` release boundary isn't known yet at implementation time, plus a `pnpm codemod:resolve-pending` step (wired into the release workflow) that resolves it to the real version constant once the release PR bumps `@tailor-platform/sdk`'s version. Prevents codemod boundaries from drifting out of sync with the version they actually ship in, which previously required a manual follow-up fix after each release. + +- [#1782](https://github.com/tailor-platform/sdk/pull/1782) [`c971797`](https://github.com/tailor-platform/sdk/commit/c971797c9bfa035a43771c46f2b1c3bd93f989a9) Thanks [@toiroakr](https://github.com/toiroakr)! - Rename `Workflow.trigger()` (returned by `createWorkflow()`) and `WorkflowJob.trigger()` (returned by `createWorkflowJob()`) to `.start()`, aligning the SDK's ergonomic verb with the platform's `start*` RPC vocabulary: + + ```diff + const inventory = checkInventory.trigger({ orderId: input.orderId }); + +const inventory = checkInventory.start({ orderId: input.orderId }); + + -const workflowRunId = await orderProcessingWorkflow.trigger(args, { invoker: "manager" }); + +const workflowRunId = await orderProcessingWorkflow.start(args, { invoker: "manager" }); + ``` + + `mockWorkflow()`'s `wf.job(definition)` / `wf.workflow(definition)` now return a mock of the `.start` method, and `wf.setTriggerHandler` / `wf.triggeredJobs` are renamed to `wf.setStartHandler` / `wf.startedJobs`. No codemod ships for the `.trigger()` → `.start()` call-site rename itself — see the `v2/workflow-start-rename` migration guide entry for manual migration steps. + +- [#1782](https://github.com/tailor-platform/sdk/pull/1782) [`c971797`](https://github.com/tailor-platform/sdk/commit/c971797c9bfa035a43771c46f2b1c3bd93f989a9) Thanks [@toiroakr](https://github.com/toiroakr)! - Remove the pre-alignment `tailor.workflow` names `triggerWorkflow`, `triggerJobFunction`, and `resumeWorkflow` (and their `TriggerWorkflowOptions` / `TriggerJobFunctionOptions` option types) from `@tailor-platform/sdk/runtime`, the ambient `@tailor-platform/sdk/runtime/globals` types, and the `mockWorkflow()` test facade. Use the canonical names instead: + + ```diff + import { workflow } from "@tailor-platform/sdk/runtime"; + + -await workflow.triggerWorkflow("myWorkflow", { data: "value" }); + +await workflow.startWorkflow("myWorkflow", { data: "value" }); + -workflow.triggerJobFunction("myJob", { data: "value" }); + +workflow.startJobFunction("myJob", { data: "value" }); + -await workflow.resumeWorkflow("execution-id"); + +await workflow.resumeWorkflowExecution("execution-id"); + ``` + + Run the `v2/workflow-trigger-rename` codemod to migrate call sites automatically. + +## 0.3.0-next.6 + +### Patch Changes + +- [#1789](https://github.com/tailor-platform/sdk/pull/1789) [`e4db171`](https://github.com/tailor-platform/sdk/commit/e4db171e2a0138ea1a0ba1a972bf895fd0616a28) Thanks [@toiroakr](https://github.com/toiroakr)! - Flag `tailor generate --watch` / `-W` invocations and programmatic `generate({ watch })` usage for manual review as part of the v2 migration (the flag and its dependency watcher are removed in `@tailor-platform/sdk` v2). + +## 0.3.0-next.5 + +### Patch Changes + +- [#1719](https://github.com/tailor-platform/sdk/pull/1719) [`4a05aec`](https://github.com/tailor-platform/sdk/commit/4a05aecfb100a1ea7292a6ae5809a2d1e6eddbfe) Thanks [@dqn](https://github.com/dqn)! - Derive default TailorDB forward relation names from the relation field name by removing a trailing `ID`, `Id`, or `id`, instead of deriving them from the target table name. + + The v2 migration review identifies non-self relations without `toward.as`. Add an explicit name to preserve the v1 GraphQL field name, or update consumers to use the new field-based name. + +- [#1678](https://github.com/tailor-platform/sdk/pull/1678) [`e0e768d`](https://github.com/tailor-platform/sdk/commit/e0e768d77470d13806ed7b2ee2117fe374d51d40) Thanks [@toiroakr](https://github.com/toiroakr)! - Redesign TailorDB hooks and validators with several breaking changes: + + - Add shared `now` timestamp to all hooks — multiple fields stamped with the same `Date` + - Field-level hooks: `{ value, data, invoker }` → create `{ input, invoker, now }` / update `{ input, oldValue, invoker, now }` (`data` removed, `oldValue` added for update only) + - Type-level hooks: per-field mapping (`Hooks`) → single `{ create, update }` object (`TypeHook`) returning partial field overrides + - Type-level create hooks no longer receive `oldRecord`; update hooks receive non-nullable `oldRecord` + - Field-level validators: return type changed from `boolean` to `string | void` (return error message or void to pass); `[fn, message]` tuple form removed + - Type-level validators: `Validators` per-field record → `TypeValidateFn` single function with `issues(field, message)` callback + - Add `.default(value)` on fields to set a create-time default (makes required fields optional in create input) + - Remove exported types: `Hooks`, `Validators`, `ValidateConfig` + +## 0.3.0-next.4 + +### Patch Changes + +- [#1693](https://github.com/tailor-platform/sdk/pull/1693) [`4751214`](https://github.com/tailor-platform/sdk/commit/4751214c0923e094a844f9ce322279a47e871075) Thanks [@dqn](https://github.com/dqn)! - Rename the TailorDB schema builder from `db.type()` to `db.table()`. + + Update TailorDB definitions: + + ```diff + import { db } from "@tailor-platform/sdk"; + + -export const user = db.type("User", { + +export const user = db.table("User", { + name: db.string(), + }); + ``` + +- [#1704](https://github.com/tailor-platform/sdk/pull/1704) [`9c81d9c`](https://github.com/tailor-platform/sdk/commit/9c81d9c18b1d29b3e9307ea17fe54c8ce55f4dda) Thanks [@dqn](https://github.com/dqn)! - Remove flat value and default exports from `@tailor-platform/sdk/runtime/*` subpath modules. Import each subpath through its self-named namespace export instead, for example `import { iconv } from "@tailor-platform/sdk/runtime/iconv"`. + + The aggregate `@tailor-platform/sdk/runtime` entry remains named-only, and its deprecated `file.deleteFile` alias is removed in favor of `file.delete`. The v2 codemod rewrites straightforward namespace-star subpath imports, flat named value imports, and aggregate `file.deleteFile` calls to the new namespace-object style. + + `TailorContextAPI` and `TailorWorkflowAPI` now describe the SDK wrapper objects. Code that types the platform-provided `globalThis.tailor.context` or `globalThis.tailor.workflow` objects directly must use `PlatformContextAPI` or `PlatformWorkflowAPI` instead. + +## 0.3.0-next.3 + +### Patch Changes + +- [#1559](https://github.com/tailor-platform/sdk/pull/1559) [`ff8ef1c`](https://github.com/tailor-platform/sdk/commit/ff8ef1c1323daf81812c182e146fd53da20e676e) Thanks [@dqn](https://github.com/dqn)! - Rename auth attribute module augmentation from `AttributeMap` to `Attributes`. + +- [#1584](https://github.com/tailor-platform/sdk/pull/1584) [`7faff07`](https://github.com/tailor-platform/sdk/commit/7faff07982909b63b87185dc1186e2919a06d4bb) Thanks [@dqn](https://github.com/dqn)! - Report the codemod runner identity in the JSON summary, including the source checkout commit and local build command when run from a branch build, so prerelease migration validation can distinguish exact npm packages from branch-head behavior. + +- [#1599](https://github.com/tailor-platform/sdk/pull/1599) [`b88f6a2`](https://github.com/tailor-platform/sdk/commit/b88f6a2e1c6d8e25a797bec6ca90428f5be3b1b9) Thanks [@dqn](https://github.com/dqn)! - Apply the v2 `rename-bin` codemod to SDK CLI command strings in TypeScript and JavaScript source files. + +- [#1578](https://github.com/tailor-platform/sdk/pull/1578) [`579cb47`](https://github.com/tailor-platform/sdk/commit/579cb4705cb295c1fcf9bff948d205fb245ff4e5) Thanks [@dqn](https://github.com/dqn)! - Run v2 codemods when the target version is a v2 prerelease. + +- [#1686](https://github.com/tailor-platform/sdk/pull/1686) [`aecaf8c`](https://github.com/tailor-platform/sdk/commit/aecaf8c1bb7813a32e998ea7d034684541cb1c85) Thanks [@dqn](https://github.com/dqn)! - Replace `tailor skills install` with project-local `tailor skills add`, `list`, `remove`, and `sync` commands for bundled Tailor SDK agent skills. + +- [#1585](https://github.com/tailor-platform/sdk/pull/1585) [`1c1ca49`](https://github.com/tailor-platform/sdk/commit/1c1ca499b4fd55a616b1531ec7ab280ceed531d3) Thanks [@dqn](https://github.com/dqn)! - Report precise file-local findings for `principal-unify` review follow-ups, including nullable caller call sites and `context.user` helper adapters. + +- [#1601](https://github.com/tailor-platform/sdk/pull/1601) [`144f3e3`](https://github.com/tailor-platform/sdk/commit/144f3e30f2b0c5dac1a3288ff65c9dc5ca82c13b) Thanks [@dqn](https://github.com/dqn)! - Fix `v2/principal-unify` review findings for nested SDK field parser invoker values and destructured context helper messages. + +- [#1622](https://github.com/tailor-platform/sdk/pull/1622) [`0fe8bad`](https://github.com/tailor-platform/sdk/commit/0fe8bad9afbb7702bc067ac9635b77c0438497a6) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated `auth.getConnectionToken()` helper from values returned by `defineAuth()`. Use `authconnection.getConnectionToken(...)` from `@tailor-platform/sdk/runtime` in resolvers, executors, and workflows instead. The v2 codemod rewrites direct `auth.getConnectionToken(...)` calls when the `auth` binding is imported from `tailor.config`. + +- [#1557](https://github.com/tailor-platform/sdk/pull/1557) [`7ff575f`](https://github.com/tailor-platform/sdk/commit/7ff575fdfa15c00b5fc6282b28c0cb50bfdf927b) Thanks [@toiroakr](https://github.com/toiroakr)! - Rename the CLI binary from `tailor-sdk` to `tailor`. + + The output directory default changes from `.tailor-sdk` to `.tailor`, and the GitHub Actions lock file path changes from `.github/tailor-sdk.lock` to `.github/tailor.lock`. + + Run the `v2/rename-bin` codemod to migrate `tailor-sdk` invocations in package.json scripts, shell scripts, CI workflows, and documentation: + + ```sh + npx @tailor-platform/sdk-codemod --from 1.x --to 2.0.0 + ``` + +- [#1563](https://github.com/tailor-platform/sdk/pull/1563) [`501e8bf`](https://github.com/tailor-platform/sdk/commit/501e8bfdd2bca7201a1c9b036bf72087476da416) Thanks [@dqn](https://github.com/dqn)! - Standardize SDK-owned environment variables on the `TAILOR_*` namespace. + + Replace the removed SDK-specific environment variables with their new names: `TAILOR_CONFIG_PATH`, `TAILOR_DTS_PATH`, `TAILOR_CI_ALLOW_ID_INJECTION`, `TAILOR_DEPLOY_BUILD_ONLY`, `TAILOR_BUILD_OUTPUT_DIR`, `TAILOR_SKILLS_SOURCE`, `TAILOR_TEMPLATE_SDK_VERSION`, `TAILOR_PLATFORM_URL`, `TAILOR_PLATFORM_OAUTH2_CLIENT_ID`, `TAILOR_INLINE_SOURCEMAP`, `TAILOR_QUERY_NEWLINE_ON_ENTER`, and `TAILOR_APP_LOG_LEVEL`. The deprecated `TAILOR_TOKEN` fallback is removed; use `TAILOR_PLATFORM_TOKEN`. The v2 codemod rewrites unambiguous removed SDK environment variable names and flags generic names such as `LOG_LEVEL` and `PLATFORM_URL` for manual review. + +- [#1684](https://github.com/tailor-platform/sdk/pull/1684) [`de3ef5e`](https://github.com/tailor-platform/sdk/commit/de3ef5e7421a998624154df5e90da62e17664524) Thanks [@dqn](https://github.com/dqn)! - Restore Tailor field outputs for UUID, date, datetime, time, and decimal fields to plain string-compatible types and remove the strict scalar string migration guidance. + +- [#1582](https://github.com/tailor-platform/sdk/pull/1582) [`b8b48a3`](https://github.com/tailor-platform/sdk/commit/b8b48a379a73314c26fbf53c74c2181e77f0565b) Thanks [@dqn](https://github.com/dqn)! - Flag JavaScript files and embedded code strings that still reference ambient Tailor runtime globals during v2 migration review. + +- [#1583](https://github.com/tailor-platform/sdk/pull/1583) [`006a588`](https://github.com/tailor-platform/sdk/commit/006a5884583f23fc6852714c41e58a7ab6d65a5a) Thanks [@dqn](https://github.com/dqn)! - Automatically migrate simple direct `tailor.idp.Client` runtime global usage to the typed `idp.Client` wrapper during v2 upgrades. + +- [#1639](https://github.com/tailor-platform/sdk/pull/1639) [`6616674`](https://github.com/tailor-platform/sdk/commit/6616674eb41a53619138603405a4498e3f09d70b) Thanks [@dqn](https://github.com/dqn)! - Keep v2 codemods from reusing type-only runtime helper imports when adding runtime value imports. + +- [#1581](https://github.com/tailor-platform/sdk/pull/1581) [`79780bc`](https://github.com/tailor-platform/sdk/commit/79780bce19864a238602f4dd7a82fcc84e9f8501) Thanks [@dqn](https://github.com/dqn)! - Add the `v2/tailor-output-ignore-dir` codemod so SDK upgrades rewrite exact `.tailor-sdk/` ignore-file entries to `.tailor/` while leaving other `.tailor-sdk` paths unchanged. + +- [#1556](https://github.com/tailor-platform/sdk/pull/1556) [`645949e`](https://github.com/tailor-platform/sdk/commit/645949ed64bda8b82fc44c0db54928698b12a2eb) Thanks [@toiroakr](https://github.com/toiroakr)! - Rename `defineWaitPoint` and `defineWaitPoints` to `createWaitPoint` and `createWaitPoints`. + + These functions create runtime instances with `.wait()` and `.resolve()` methods that call the platform API at runtime, so the `create*` prefix is more accurate. Update any usages: + + ```diff + -import { defineWaitPoint, defineWaitPoints } from "@tailor-platform/sdk"; + +import { createWaitPoint, createWaitPoints } from "@tailor-platform/sdk"; + + -export const approval = defineWaitPoint("approval"); + +export const approval = createWaitPoint("approval"); + + -export const waitPoints = defineWaitPoints((define) => ({ ... })); + +export const waitPoints = createWaitPoints((define) => ({ ... })); + ``` + +## 0.3.0-next.2 +### Minor Changes + + + +- [#1473](https://github.com/tailor-platform/sdk/pull/1473) [`7ddf3c7`](https://github.com/tailor-platform/sdk/commit/7ddf3c716adf85a66a75d554da7730b5406f84b1) Thanks [@dqn](https://github.com/dqn)! - Add the `v2/principal-unify` codemod so `tailor-sdk upgrade` can migrate SDK principal APIs to `TailorPrincipal`. + + +### Patch Changes + + + +- [#1515](https://github.com/tailor-platform/sdk/pull/1515) [`dcf66a1`](https://github.com/tailor-platform/sdk/commit/dcf66a1e648f5287eaea9ea330eb4ad726a4d363) Thanks [@dqn](https://github.com/dqn)! - Rewrite `tailor-sdk apply` to `tailor-sdk deploy` in source files that contain embedded CLI command strings. + + + +- [#1482](https://github.com/tailor-platform/sdk/pull/1482) [`8b5870e`](https://github.com/tailor-platform/sdk/commit/8b5870e85db1efec7647acb98226f8161e3d1583) Thanks [@toiroakr](https://github.com/toiroakr)! - Add LLM-assisted review support to the codemod runner. A codemod can declare `suspiciousPatterns` plus a `prompt`; after running, files whose post-transform content still matches a suspicious pattern are reported as `llmReviews` (in the JSON output and on stderr) together with the codemod's migration prompt. This surfaces the cases a deterministic transform cannot safely complete (e.g. a value reached through a variable) so they can be finished with an LLM. The `auth.invoker(...)` codemod adopts this for its non-literal-argument cases. + + + +- [#1495](https://github.com/tailor-platform/sdk/pull/1495) [`6234022`](https://github.com/tailor-platform/sdk/commit/6234022d7dc03813b8dade831b86f63a5f7a20e6) Thanks [@toiroakr](https://github.com/toiroakr)! - Generate the v2 migration guide (`packages/sdk/docs/migration/v2.md`) from the codemod registry, which is the single source of truth. Each entry renders its name, automation level (Automatic / Partially automatic / Manual), description, optional before/after `examples`, and — for changes the codemods cannot fully migrate on their own — the LLM/manual migration prompt. Run `pnpm codemod:docs:update` to regenerate and `pnpm codemod:docs:check` (wired into `pnpm check`) to verify it is in sync. + + `scriptPath` is now optional, so the registry can also describe codemod-less ("manual") migrations that ship only guidance (`examples` / `prompt` / `suspiciousPatterns`) with no automatic transform. A manual entry with a `prompt` but no scoping pattern is surfaced as a project-wide `llmReviews` entry at runtime. + + Add a `sdk-codemod list` command that prints every registered rule (id, name, kind, version range). + + +- [#1517](https://github.com/tailor-platform/sdk/pull/1517) [`a649764`](https://github.com/tailor-platform/sdk/commit/a6497649be2786b3f6e410c8aa98c4247a599258) Thanks [@dqn](https://github.com/dqn)! - Reduce false-positive v2 codemod warnings and LLM-review prompts from source comments, string literals, and identifier substring matches. + + + +- [#1476](https://github.com/tailor-platform/sdk/pull/1476) [`fa83075`](https://github.com/tailor-platform/sdk/commit/fa83075f5e0e91085c0ef0cb44b7058a28a79ec3) Thanks [@toiroakr](https://github.com/toiroakr)! - `executeScript` now takes its `arg` as a JSON-serializable value instead of a pre-serialized JSON string. Pass the value directly (e.g. `arg: { a: 1 }`) instead of `arg: JSON.stringify({ a: 1 })`. + + Add the `v2/execute-script-arg` codemod, which unwraps `JSON.stringify(...)` passed as the `executeScript` `arg` option. Indirect forms (a stringified value held in a variable, etc.) cannot be rewritten automatically and are surfaced as an LLM-assisted review task with a migration prompt. + + +- [#1518](https://github.com/tailor-platform/sdk/pull/1518) [`ab10b1f`](https://github.com/tailor-platform/sdk/commit/ab10b1fea309ec5496e09bdca394d46d58603f5f) Thanks [@dqn](https://github.com/dqn)! - Reduce noisy `executeScript` LLM-review prompts by flagging files only when unresolved `arg` stringification remains likely. + + + +- [#1509](https://github.com/tailor-platform/sdk/pull/1509) [`7cadaa7`](https://github.com/tailor-platform/sdk/commit/7cadaa7c4987b81130ca80ba80bc5d5b26276394) Thanks [@dqn](https://github.com/dqn)! - Rename resolver, executor, workflow trigger, and typed workflow start machine-user options from `authInvoker` to `invoker`. + + Update create-sdk templates and the v2 auth invoker codemod to generate the new `invoker` option. + + +- [#1519](https://github.com/tailor-platform/sdk/pull/1519) [`4e3fa47`](https://github.com/tailor-platform/sdk/commit/4e3fa47d24e6bb1145eac13c355e976f2d594851) Thanks [@dqn](https://github.com/dqn)! - Limit the `openDownloadStream` migration review prompt to files that reference deprecated download stream APIs. + + + +- [#1521](https://github.com/tailor-platform/sdk/pull/1521) [`2d0689e`](https://github.com/tailor-platform/sdk/commit/2d0689e8ac0079473294fab367799a5431c130f4) Thanks [@dqn](https://github.com/dqn)! - Flag files that need project-specific review after the v2 principal migration, including resolver helper adapters and nullable `caller` follow-ups. + + + +- [#1525](https://github.com/tailor-platform/sdk/pull/1525) [`425a19d`](https://github.com/tailor-platform/sdk/commit/425a19dd58da6e373b739d3b3e838c2ff3d1736a) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency semver to v7.8.5 + + + +- [#1520](https://github.com/tailor-platform/sdk/pull/1520) [`ed3d338`](https://github.com/tailor-platform/sdk/commit/ed3d338ce71d68904ef1fb83afbbd06a7e5f6973) Thanks [@dqn](https://github.com/dqn)! - Flag files that still reference ambient Tailor runtime globals so the v2 migration can opt them into `@tailor-platform/sdk/runtime/globals`. + + + +- [#1439](https://github.com/tailor-platform/sdk/pull/1439) [`c5b10d2`](https://github.com/tailor-platform/sdk/commit/c5b10d2841ded08927285bce538c05220cde5e4c) Thanks [@dqn](https://github.com/dqn)! - Unify function principal context around `TailorPrincipal`. + + Resolver contexts now use `caller` and `invoker` as `TailorPrincipal | null`, workflow and executor invokers also use `TailorPrincipal | null`, and event executor `actor` uses `TailorPrincipal | null` with `id`/`type` fields. The legacy `TailorUser`, `TailorInvoker`, `TailorActor`, `TailorActorType`, and `unauthenticatedTailorUser` exports are removed. + +## 0.3.0-next.1 +### Patch Changes + + + +- [#1460](https://github.com/tailor-platform/sdk/pull/1460) [`f49c6d1`](https://github.com/tailor-platform/sdk/commit/f49c6d1b5a856969cb4e04ae7d3a87ed34aa020f) Thanks [@dqn](https://github.com/dqn)! - Remove the v1 runtime globals compatibility layer. Importing from `@tailor-platform/sdk` no longer activates the ambient `tailor.*` / `tailordb.*` declarations; opt into globals with `@tailor-platform/sdk/runtime/globals` or use the typed wrappers from `@tailor-platform/sdk/runtime`. + + The capital-cased `Tailordb.*` namespace is removed. If your project still references `Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, or `typeof Tailordb.Client`, migrate before upgrading: run `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` to rewrite them to lowercase `tailordb.*`, then add `import "@tailor-platform/sdk/runtime/globals"` so the rewritten references resolve. + + +- [#1457](https://github.com/tailor-platform/sdk/pull/1457) [`84325f8`](https://github.com/tailor-platform/sdk/commit/84325f8602a5631b7c323c997b1425235509920e) Thanks [@dqn](https://github.com/dqn)! - Remove deprecated CLI aliases for the v2 command surface. Use `tailor-sdk deploy` instead of `tailor-sdk apply`, `tailor-sdk crashreport` instead of `tailor-sdk crash-report`, and the hyphenated `--machine-user` option instead of the hidden `--machineuser` alias. + + Fix the v2 CLI rename codemod to migrate the hidden `--machineuser` option to `--machine-user`. + +## 0.3.0-next.0 +### Minor Changes + + + +- [#1435](https://github.com/tailor-platform/sdk/pull/1435) [`49c0cc9`](https://github.com/tailor-platform/sdk/commit/49c0cc99171d7e317a50a18804a21067d89f9493) Thanks [@dqn](https://github.com/dqn)! - Add the `v2/plugin-cli-import` codemod so `tailor-sdk upgrade` rewrites deprecated plugin imports from `@tailor-platform/sdk/cli` (`kyselyTypePlugin`, `enumConstantsPlugin`, `fileUtilsPlugin`, `seedPlugin`) to their dedicated `@tailor-platform/sdk/plugin/*` subpaths, splitting any non-plugin specifiers onto a separate import. + +## 0.3.0 +### Minor Changes + + + +- [#1435](https://github.com/tailor-platform/sdk/pull/1435) [`49c0cc9`](https://github.com/tailor-platform/sdk/commit/49c0cc99171d7e317a50a18804a21067d89f9493) Thanks [@dqn](https://github.com/dqn)! - Add the `v2/plugin-cli-import` codemod so `tailor-sdk upgrade` rewrites deprecated plugin imports from `@tailor-platform/sdk/cli` (`kyselyTypePlugin`, `enumConstantsPlugin`, `fileUtilsPlugin`, `seedPlugin`) to their dedicated `@tailor-platform/sdk/plugin/*` subpaths, splitting any non-plugin specifiers onto a separate import. + ## 0.3.7 ### Patch Changes diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/codemod.yaml b/packages/sdk-codemod/codemods/v2/apply-to-deploy/codemod.yaml index f9a072de5..23c63a08c 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/codemod.yaml +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/codemod.yaml @@ -1,6 +1,6 @@ name: "@tailor-platform/apply-to-deploy" version: "1.0.0" -description: "Rewrite `tailor-sdk apply` invocations to `tailor-sdk deploy` (the v2 recommended name)" +description: "Rewrite `tailor-sdk apply` invocations to the canonical v2 `tailor-sdk deploy` command" engine: jssg language: text since: "1.0.0" diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/apply-to-deploy/scripts/transform.ts index bf50fec76..a262d71c2 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/scripts/transform.ts +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/scripts/transform.ts @@ -2,17 +2,20 @@ import * as path from "pathe"; // Match `tailor-sdk apply` plus the optional `@version` suffix that // package-manager run commands can add (`npx tailor-sdk@latest apply`, -// `pnpm dlx tailor-sdk@1.45.2 apply`). The version pin is preserved because -// `apply` and `deploy` are the same subcommand on the same binary. +// `pnpm dlx tailor-sdk@1.45.2 apply`). The version pin is preserved; only the +// command spelling changes. // `(?![-\w])` excludes both word continuation (`applyConfig`) and dash-suffixed // names (`apply-foo`) so a hypothetical sibling subcommand is not rewritten. -const APPLY_PATTERN = /\btailor-sdk(@[^\s'"`]+)?(\s+)apply(?![-\w])/g; +const ARG_VALUE = `(?:[^\\s'"\`;&|]+|'[^']*'|"(?:(?:\\\\.)|[^"\\\\])*")`; +const BOOLEAN_GLOBAL_ARG = "(?:--verbose|--json|-j)"; +const VALUE_GLOBAL_ARG = "(?:--env-file|--env-file-if-exists|-e)"; +const GLOBAL_ARG_PATTERN = `(?:(?:\\s+${BOOLEAN_GLOBAL_ARG})|(?:\\s+${VALUE_GLOBAL_ARG}(?:=${ARG_VALUE}|\\s+${ARG_VALUE})))*`; +const TAILOR_BINARY = `(? `tailor-sdk${ver ?? ""}${sep}deploy`, - ); + return value.replace(APPLY_PATTERN, (match) => `${match.slice(0, -"apply".length)}deploy`); } function transformText(source: string): string | null { @@ -22,6 +25,14 @@ function transformText(source: string): string | null { return updated === source ? null : updated; } +function transformSourceText(source: string): string | null { + const updated = source + .split(/(\r\n|\n|\r)/) + .map((part, index) => (index % 2 === 0 ? replaceApply(part) : part)) + .join(""); + return updated === source ? null : updated; +} + function transformPackageJson(source: string): string | null { let parsed: Record; try { @@ -52,7 +63,7 @@ function transformPackageJson(source: string): string | null { /** * Replace `tailor-sdk apply` invocations with `tailor-sdk deploy`. * - * `deploy` is a v1 alias of `apply` and the recommended name going forward. + * `deploy` is the canonical v2 command name. * @param source - File contents * @param filePath - Absolute path to the file (used to dispatch package.json vs text) * @returns Transformed source or null when nothing matched. @@ -62,5 +73,6 @@ export default function transform(source: string, filePath: string): string | nu const ext = path.extname(filePath).toLowerCase(); if (ext === ".json") return transformPackageJson(source); + if (SOURCE_EXTENSIONS.has(ext)) return transformSourceText(source); return transformText(source); } diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/expected.json b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/expected.json index cb68f1c59..9cbc21b33 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/expected.json +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/expected.json @@ -4,6 +4,8 @@ "scripts": { "deploy:dev": "tailor-sdk deploy --profile dev", "deploy:prod": "tailor-sdk deploy --profile prod -y", + "deploy:json": "tailor-sdk --json deploy --dry-run", + "deploy:env": "tailor-sdk --env-file=.env deploy --yes", "build": "tsc" } } diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/input.json b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/input.json index 26cdfa98a..2c2ad6558 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/input.json +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/input.json @@ -4,6 +4,8 @@ "scripts": { "deploy:dev": "tailor-sdk apply --profile dev", "deploy:prod": "tailor-sdk apply --profile prod -y", + "deploy:json": "tailor-sdk --json apply --dry-run", + "deploy:env": "tailor-sdk --env-file=.env apply --yes", "build": "tsc" } } diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/expected.sh b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/expected.sh index c3705af3f..64f8adfff 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/expected.sh +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/expected.sh @@ -4,3 +4,5 @@ set -euo pipefail pnpm exec tailor-sdk deploy --dry-run npx tailor-sdk deploy -y --profile prod bunx tailor-sdk deploy --no-cache +tailor-sdk --env-file .env deploy --yes +tailor-sdk --json deploy --dry-run diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/input.sh b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/input.sh index f197add5e..2ccbe800c 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/input.sh +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/input.sh @@ -4,3 +4,5 @@ set -euo pipefail pnpm exec tailor-sdk apply --dry-run npx tailor-sdk apply -y --profile prod bunx tailor-sdk apply --no-cache +tailor-sdk --env-file .env apply --yes +tailor-sdk --json apply --dry-run diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/no-match/input.sh b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/no-match/input.sh index 004f8c9d8..9e883b8f5 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/no-match/input.sh +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/no-match/input.sh @@ -7,3 +7,6 @@ echo "How to apply this configuration" pnpm exec tailor-sdk apply-foo # Should not match: word continuation pnpm exec tailor-sdk applyConfig +# Should not match: wrapper or unrelated binary names +tailor-sdk-wrapper apply --yes +my-tailor-sdk apply --yes diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-comment-no-match/input.ts b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-comment-no-match/input.ts new file mode 100644 index 000000000..b5bd01540 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-comment-no-match/input.ts @@ -0,0 +1,2 @@ +// tailor-sdk +apply(config); diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/expected.ts b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/expected.ts new file mode 100644 index 000000000..7da5667d7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/expected.ts @@ -0,0 +1,7 @@ +import { expect, test } from "vitest"; + +test("keeps collected scripts", () => { + expect(JSON.stringify({ scripts: { deploy: "tailor-sdk deploy", seed: "node seed.mjs" } })).toBe( + '{"scripts":{"deploy":"tailor-sdk deploy","seed":"node seed.mjs"}}', + ); +}); diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/input.ts b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/input.ts new file mode 100644 index 000000000..d9b3dd8c0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/input.ts @@ -0,0 +1,7 @@ +import { expect, test } from "vitest"; + +test("keeps collected scripts", () => { + expect(JSON.stringify({ scripts: { deploy: "tailor-sdk apply", seed: "node seed.mjs" } })).toBe( + '{"scripts":{"deploy":"tailor-sdk apply","seed":"node seed.mjs"}}', + ); +}); diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-jsx-comment-no-match/input.jsx b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-jsx-comment-no-match/input.jsx new file mode 100644 index 000000000..b5bd01540 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-jsx-comment-no-match/input.jsx @@ -0,0 +1,2 @@ +// tailor-sdk +apply(config); diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/codemod.yaml b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/codemod.yaml new file mode 100644 index 000000000..4913a398f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/auth-attributes-rename" +version: "1.0.0" +description: "Rename auth attribute type names from AttributeMap to Attributes" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/scripts/transform.ts new file mode 100644 index 000000000..943fec080 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/scripts/transform.ts @@ -0,0 +1,307 @@ +import { parse, Lang } from "@ast-grep/napi"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const SDK_MODULE = "@tailor-platform/sdk"; + +const TYPE_RENAME_MAP: Record = { + AttributeMap: "Attributes", + UserAttributeMap: "UserAttributes", + InferredAttributeMap: "InferredAttributes", +}; + +function quickFilter(source: string): boolean { + return ( + source.includes(SDK_MODULE) && + Object.keys(TYPE_RENAME_MAP).some((name) => source.includes(name)) + ); +} + +function isSdkModuleLiteral(node: SgNode): boolean { + return node.kind() === "string" && /^["']@tailor-platform\/sdk["']$/.test(node.text()); +} + +function hasSdkModuleLiteral(node: SgNode): boolean { + return node.findAll({ rule: { kind: "string" } }).some(isSdkModuleLiteral); +} + +function moduleSpecifierLiteral(node: SgNode): SgNode | undefined { + const directLiteral = node.children().find((child: SgNode) => child.kind() === "string"); + if (directLiteral) return directLiteral; + const moduleNode = node.children().find((child: SgNode) => child.kind() === "module"); + return moduleNode?.children().find((child: SgNode) => child.kind() === "string"); +} + +function hasSdkModuleSpecifier(node: SgNode): boolean { + const literal = moduleSpecifierLiteral(node); + return literal ? isSdkModuleLiteral(literal) : false; +} + +function identifierChildren(node: SgNode): SgNode[] { + return node.children().filter((child: SgNode) => child.kind() === "identifier"); +} + +function typeIdentifierChildren(node: SgNode): SgNode[] { + return node.children().filter((child: SgNode) => child.kind() === "type_identifier"); +} + +function sameRange(a: SgNode, b: SgNode): boolean { + const ar = a.range(); + const br = b.range(); + return ar.start.index === br.start.index && ar.end.index === br.end.index; +} + +function addReplacement( + edits: Edit[], + editedRanges: Set, + node: SgNode, + replacement: string, +): void { + if (node.text() === replacement) return; + const r = node.range(); + const key = `${r.start.index}:${r.end.index}`; + if (editedRanges.has(key)) return; + editedRanges.add(key); + edits.push(node.replace(replacement)); +} + +function renamedType(name: string): string | undefined { + return TYPE_RENAME_MAP[name]; +} + +function isDeclarationName(node: SgNode): boolean { + const parent = node.parent(); + if ( + !parent || + ![ + "class_declaration", + "enum_declaration", + "interface_declaration", + "type_alias_declaration", + "type_parameter", + ].includes(parent.kind() as string) + ) { + return false; + } + const name = parent?.field("name"); + return !!name && sameRange(name, node); +} + +function isNestedTypeName(node: SgNode): boolean { + return node.parent()?.kind() === "nested_type_identifier"; +} + +function declarationName(node: SgNode): SgNode | undefined { + if ( + [ + "class_declaration", + "enum_declaration", + "interface_declaration", + "type_alias_declaration", + ].includes(node.kind() as string) + ) { + return node.field("name") ?? undefined; + } + if (node.kind() !== "export_statement") return undefined; + const declaration = node + .children() + .find((child: SgNode) => + [ + "class_declaration", + "enum_declaration", + "interface_declaration", + "type_alias_declaration", + ].includes(child.kind() as string), + ); + return declaration?.field("name") ?? undefined; +} + +function scopeDeclaresType(scope: SgNode, name: string, reference: SgNode): boolean { + return scope.children().some((child: SgNode) => { + const declaredName = declarationName(child); + return !!declaredName && declaredName.text() === name && !sameRange(declaredName, reference); + }); +} + +function hasTypeParameterShadow(node: SgNode, name: string): boolean { + const parameters = node.findAll({ rule: { kind: "type_parameter" } }); + return parameters.some((parameter) => typeIdentifierChildren(parameter)[0]?.text() === name); +} + +function isShadowedTypeReference(node: SgNode, name: string): boolean { + let current = node.parent(); + while (current) { + if (current.kind() === "statement_block" && scopeDeclaresType(current, name, node)) { + return true; + } + if ( + [ + "class_declaration", + "function_declaration", + "interface_declaration", + "method_definition", + "type_alias_declaration", + ].includes(current.kind() as string) && + hasTypeParameterShadow(current, name) + ) { + return true; + } + current = current.parent(); + } + return false; +} + +function collectSdkImports( + root: SgNode, + edits: Edit[], + editedRanges: Set, +): { + localTypeRenames: Map; + namespaceNames: Set; +} { + const localTypeRenames = new Map(); + const namespaceNames = new Set(); + const importStmts = root.findAll({ rule: { kind: "import_statement" } }); + + for (const importStmt of importStmts) { + if (!hasSdkModuleSpecifier(importStmt)) continue; + + const namespaceImports = importStmt.findAll({ rule: { kind: "namespace_import" } }); + for (const namespaceImport of namespaceImports) { + const localName = identifierChildren(namespaceImport).at(-1)?.text(); + if (localName) namespaceNames.add(localName); + } + + const specs = importStmt.findAll({ rule: { kind: "import_specifier" } }); + for (const spec of specs) { + const identifiers = identifierChildren(spec); + const imported = identifiers[0]; + if (!imported) continue; + const replacement = renamedType(imported.text()); + if (!replacement) continue; + + addReplacement(edits, editedRanges, imported, replacement); + if (identifiers.length === 1) { + localTypeRenames.set(imported.text(), replacement); + } + } + } + + return { localTypeRenames, namespaceNames }; +} + +function rewriteSdkExports(root: SgNode, edits: Edit[], editedRanges: Set): void { + const exportStmts = root.findAll({ rule: { kind: "export_statement" } }); + for (const exportStmt of exportStmts) { + if (!hasSdkModuleSpecifier(exportStmt)) continue; + + const specs = exportStmt.findAll({ rule: { kind: "export_specifier" } }); + for (const spec of specs) { + const exported = identifierChildren(spec)[0]; + if (!exported) continue; + const replacement = renamedType(exported.text()); + if (replacement) addReplacement(edits, editedRanges, exported, replacement); + } + } +} + +function rewriteModuleAugmentations(root: SgNode, edits: Edit[], editedRanges: Set): void { + const declarations = root.findAll({ rule: { kind: "ambient_declaration" } }); + for (const declaration of declarations) { + if (!hasSdkModuleSpecifier(declaration)) continue; + + const interfaces = declaration.findAll({ rule: { kind: "interface_declaration" } }); + for (const iface of interfaces) { + const name = typeIdentifierChildren(iface)[0]; + if (name?.text() === "AttributeMap") { + addReplacement(edits, editedRanges, name, "Attributes"); + } + } + } +} + +function rewriteLocalTypeReferences( + root: SgNode, + edits: Edit[], + editedRanges: Set, + localTypeRenames: Map, +): void { + if (localTypeRenames.size === 0) return; + + const typeIdentifiers = root.findAll({ rule: { kind: "type_identifier" } }); + for (const typeIdentifier of typeIdentifiers) { + if (isDeclarationName(typeIdentifier) || isNestedTypeName(typeIdentifier)) continue; + const replacement = localTypeRenames.get(typeIdentifier.text()); + if (replacement && isShadowedTypeReference(typeIdentifier, typeIdentifier.text())) continue; + if (replacement) addReplacement(edits, editedRanges, typeIdentifier, replacement); + } +} + +function rewriteNamespaceTypeReferences( + root: SgNode, + edits: Edit[], + editedRanges: Set, + namespaceNames: Set, +): void { + if (namespaceNames.size === 0) return; + + const nestedTypes = root.findAll({ rule: { kind: "nested_type_identifier" } }); + for (const nestedType of nestedTypes) { + const namespaceName = identifierChildren(nestedType)[0]?.text(); + if (!namespaceName || !namespaceNames.has(namespaceName)) continue; + const typeName = typeIdentifierChildren(nestedType).at(-1); + if (!typeName) continue; + const replacement = renamedType(typeName.text()); + if (replacement) addReplacement(edits, editedRanges, typeName, replacement); + } +} + +function isSdkImportCall(node: SgNode): boolean { + return node.kind() === "call_expression" && hasSdkModuleLiteral(node); +} + +function rewriteImportTypeReferences(root: SgNode, edits: Edit[], editedRanges: Set): void { + const members = root.findAll({ rule: { kind: "member_expression" } }); + for (const member of members) { + const object = member.field("object"); + if (!object || !isSdkImportCall(object)) continue; + const property = member.field("property"); + if (!property || property.kind() !== "property_identifier") continue; + const replacement = renamedType(property.text()); + if (replacement) addReplacement(edits, editedRanges, property, replacement); + } +} + +/** + * Rename the v1 auth attribute type API to its v2 names only when a reference + * can be tied to `@tailor-platform/sdk`. + * @param source - File contents + * @param _filePath - Absolute path to the file (kept for the runner signature) + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, _filePath?: string): string | null { + if (!quickFilter(source)) return null; + + const filePath = _filePath?.toLowerCase(); + const lang = + filePath?.endsWith(".tsx") || filePath?.endsWith(".jsx") + ? Lang.Tsx + : filePath + ? Lang.TypeScript + : source.includes("") + ? Lang.Tsx + : Lang.TypeScript; + const root = parse(lang, source).root(); + + const edits: Edit[] = []; + const editedRanges = new Set(); + + const { localTypeRenames, namespaceNames } = collectSdkImports(root, edits, editedRanges); + rewriteSdkExports(root, edits, editedRanges); + rewriteModuleAugmentations(root, edits, editedRanges); + rewriteLocalTypeReferences(root, edits, editedRanges, localTypeRenames); + rewriteNamespaceTypeReferences(root, edits, editedRanges, namespaceNames); + rewriteImportTypeReferences(root, edits, editedRanges); + + if (edits.length === 0) return null; + return root.commitEdits(edits); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/expected.ts new file mode 100644 index 000000000..2a0d7226b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/expected.ts @@ -0,0 +1,7 @@ +import type { + Attributes as AuthAttributes, + UserAttributes as AuthUserAttributes, +} from "@tailor-platform/sdk"; + +type Auth = AuthAttributes; +type User = AuthUserAttributes; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/input.ts new file mode 100644 index 000000000..17a948308 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/input.ts @@ -0,0 +1,7 @@ +import type { + AttributeMap as AuthAttributes, + UserAttributeMap as AuthUserAttributes, +} from "@tailor-platform/sdk"; + +type Auth = AuthAttributes; +type User = AuthUserAttributes; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/expected.ts new file mode 100644 index 000000000..5aebaba69 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/expected.ts @@ -0,0 +1,2 @@ +type Auth = import("@tailor-platform/sdk").Attributes; +type User = import("@tailor-platform/sdk").UserAttributes; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/input.ts new file mode 100644 index 000000000..5dfb88239 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/input.ts @@ -0,0 +1,2 @@ +type Auth = import("@tailor-platform/sdk").AttributeMap; +type User = import("@tailor-platform/sdk").UserAttributeMap; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/local-interface-untouched/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/local-interface-untouched/input.ts new file mode 100644 index 000000000..9d197530c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/local-interface-untouched/input.ts @@ -0,0 +1,5 @@ +interface AttributeMap { + role: string; +} + +type UserAttributeMap = AttributeMap; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/expected.ts new file mode 100644 index 000000000..cabd68b47 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/expected.ts @@ -0,0 +1,11 @@ +declare module "@tailor-platform/sdk" { + interface Attributes { + role: string; + } +} + +declare module "other" { + interface AttributeMap { + role: string; + } +} diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/input.ts new file mode 100644 index 000000000..38db5e5b6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/input.ts @@ -0,0 +1,11 @@ +declare module "@tailor-platform/sdk" { + interface AttributeMap { + role: string; + } +} + +declare module "other" { + interface AttributeMap { + role: string; + } +} diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-string-untouched/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-string-untouched/input.ts new file mode 100644 index 000000000..2cbcab067 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-string-untouched/input.ts @@ -0,0 +1,7 @@ +declare module "other" { + type SdkModule = "@tailor-platform/sdk"; + + interface AttributeMap { + role: string; + } +} diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/expected.ts new file mode 100644 index 000000000..79b7a127f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/expected.ts @@ -0,0 +1,5 @@ +import type * as sdk from "@tailor-platform/sdk"; + +type Auth = sdk.Attributes; +type User = sdk.UserAttributes; +type Inferred = sdk.InferredAttributes<"auth">; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/input.ts new file mode 100644 index 000000000..c79434923 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/input.ts @@ -0,0 +1,5 @@ +import type * as sdk from "@tailor-platform/sdk"; + +type Auth = sdk.AttributeMap; +type User = sdk.UserAttributeMap; +type Inferred = sdk.InferredAttributeMap<"auth">; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/expected.ts new file mode 100644 index 000000000..9537630fb --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/expected.ts @@ -0,0 +1,11 @@ +import type { Attributes } from "@tailor-platform/sdk"; + +namespace Local { + export interface AttributeMap { + local: string; + } + + export type LocalAttrs = AttributeMap; +} + +type SdkAttrs = Attributes; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/input.ts new file mode 100644 index 000000000..77b1d1a0f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/input.ts @@ -0,0 +1,11 @@ +import type { AttributeMap } from "@tailor-platform/sdk"; + +namespace Local { + export interface AttributeMap { + local: string; + } + + export type LocalAttrs = AttributeMap; +} + +type SdkAttrs = AttributeMap; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/expected.ts new file mode 100644 index 000000000..5f5578485 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/expected.ts @@ -0,0 +1,2 @@ +export type { Attributes, InferredAttributes, UserAttributes as AuthUserAttributeMap } from "@tailor-platform/sdk"; +export type { AttributeMap as OtherAttributeMap } from "other"; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/input.ts new file mode 100644 index 000000000..98ce0945a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/input.ts @@ -0,0 +1,2 @@ +export type { AttributeMap, InferredAttributeMap, UserAttributeMap as AuthUserAttributeMap } from "@tailor-platform/sdk"; +export type { AttributeMap as OtherAttributeMap } from "other"; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/runtime-attribute-map-untouched/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/runtime-attribute-map-untouched/input.ts new file mode 100644 index 000000000..5f9ef5eda --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/runtime-attribute-map-untouched/input.ts @@ -0,0 +1,5 @@ +import { protoMachineUserAttributeMap } from "@tailor-platform/sdk/internal"; + +const attributeMap = { role: "admin" }; + +protoMachineUserAttributeMap(attributeMap); diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/expected.ts new file mode 100644 index 000000000..d3cbf9e46 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/expected.ts @@ -0,0 +1,5 @@ +import type { Attributes, InferredAttributes, UserAttributes } from "@tailor-platform/sdk"; + +type AuthAttributes = Attributes; +type User = UserAttributes; +type Inferred = InferredAttributes<"auth">; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/input.ts new file mode 100644 index 000000000..a5665ee21 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/input.ts @@ -0,0 +1,5 @@ +import type { AttributeMap, InferredAttributeMap, UserAttributeMap } from "@tailor-platform/sdk"; + +type AuthAttributes = AttributeMap; +type User = UserAttributeMap; +type Inferred = InferredAttributeMap<"auth">; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/expected.ts new file mode 100644 index 000000000..85b45d4f5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/expected.ts @@ -0,0 +1,4 @@ +import type { Attributes } from "@tailor-platform/sdk"; + +const marker = "/>"; +const attrs = {}; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/input.ts new file mode 100644 index 000000000..ae0af5685 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/input.ts @@ -0,0 +1,4 @@ +import type { AttributeMap } from "@tailor-platform/sdk"; + +const marker = "/>"; +const attrs = {}; diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/codemod.yaml b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/codemod.yaml new file mode 100644 index 000000000..b6cf26447 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/auth-connection-token-helper" +version: "1.0.0" +description: "Replace defineAuth auth.getConnectionToken() calls with the authconnection runtime wrapper where the auth binding is imported from tailor.config" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/scripts/transform.ts new file mode 100644 index 000000000..d67776af5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/scripts/transform.ts @@ -0,0 +1,345 @@ +import { parse, Lang } from "@ast-grep/napi"; +import { + buildAddNamedImportEdit, + findImportStatements, + importBindings, + importSource, + importSpecNames, + isTypeOnlyImport, + localDeclarationNames, +} from "../../../../src/ast-grep-helpers"; +import type { LlmReviewFinding } from "../../../../src/types"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const RUNTIME_MODULE = "@tailor-platform/sdk/runtime"; +const AUTHCONNECTION = "authconnection"; +const GET_CONNECTION_TOKEN = "getConnectionToken"; +const JSX_FILE_EXTENSIONS = new Set([".tsx", ".jsx"]); +const JS_FILE_EXTENSIONS = new Set([".js", ".mjs", ".cjs"]); + +interface AuthImport { + importStmt: SgNode; + localName: string; + spec: SgNode; +} + +interface TokenCall { + objectNode: SgNode; + localName: string; + range: [number, number]; +} + +function quickFilter(source: string): boolean { + return source.includes(GET_CONNECTION_TOKEN); +} + +function sourceLang(filePath: string, source: string): Lang { + const lower = filePath.toLowerCase(); + const extension = lower.slice(lower.lastIndexOf(".")); + if (JSX_FILE_EXTENSIONS.has(extension)) return Lang.Tsx; + if (JS_FILE_EXTENSIONS.has(extension) && /<>|<\/>|<[A-Za-z][\w.$:-]/.test(source)) { + return Lang.Tsx; + } + return Lang.TypeScript; +} + +function parseRoot(source: string, filePath: string): SgNode | null { + if (!quickFilter(source)) return null; + try { + return parse(sourceLang(filePath, source), source).root(); + } catch { + return null; + } +} + +function isTailorConfigSource(source: string): boolean { + return /(^|\/)tailor\.config(?:\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs))?$/.test(source); +} + +function findAuthImports(imports: SgNode[]): AuthImport[] { + const authImports: AuthImport[] = []; + for (const importStmt of imports) { + const source = importSource(importStmt); + if (!source || !isTailorConfigSource(source) || isTypeOnlyImport(importStmt)) continue; + + for (const spec of importStmt.findAll({ rule: { kind: "import_specifier" } })) { + const names = importSpecNames(spec); + if (names?.importedName !== "auth" || names.typeOnly) continue; + authImports.push({ importStmt, localName: names.localName, spec }); + } + } + return authImports; +} + +function runtimeAuthconnectionReference(imports: SgNode[]): string | null { + for (const importStmt of imports) { + if (importSource(importStmt) !== RUNTIME_MODULE || isTypeOnlyImport(importStmt)) continue; + for (const binding of importBindings(importStmt)) { + if (binding.importedName === AUTHCONNECTION && !binding.typeOnly) { + return binding.localName; + } + } + + const clause = importStmt.children().find((child) => child.kind() === "import_clause"); + const namespace = clause?.children().find((child) => child.kind() === "namespace_import"); + const local = namespace?.children().find((child) => child.kind() === "identifier"); + if (local) return `${local.text()}.${AUTHCONNECTION}`; + } + return null; +} + +function hasRuntimeImportCollision(root: SgNode, imports: SgNode[]): boolean { + if (localDeclarationNames(root).has(AUTHCONNECTION)) return true; + return imports.some( + (importStmt) => + importSource(importStmt) !== RUNTIME_MODULE && + importBindings(importStmt).some( + (binding) => binding.localName === AUTHCONNECTION && !binding.typeOnly, + ), + ); +} + +function hasAuthLocalCollision(root: SgNode, authLocalNames: Set): boolean { + const localNames = localDeclarationNames(root); + return Array.from(authLocalNames).some((name) => localNames.has(name)); +} + +function findDirectAuthCalls(root: SgNode, authLocalNames: Set): TokenCall[] { + const calls: TokenCall[] = []; + for (const call of root.findAll({ rule: { kind: "call_expression" } })) { + const callee = call.field("function"); + if (callee?.kind() !== "member_expression") continue; + + const object = callee.field("object"); + const property = callee.field("property"); + if ( + object?.kind() !== "identifier" || + property?.text() !== GET_CONNECTION_TOKEN || + !authLocalNames.has(object.text()) + ) { + continue; + } + + const range = object.range(); + calls.push({ + objectNode: object, + localName: object.text(), + range: [range.start.index, range.end.index], + }); + } + return calls; +} + +function isInsideImportStatement(node: SgNode): boolean { + let current = node.parent(); + while (current) { + if (current.kind() === "import_statement") return true; + current = current.parent(); + } + return false; +} + +function isInsideScheduledRange(node: SgNode, ranges: Array<[number, number]>): boolean { + const start = node.range().start.index; + return ranges.some(([rangeStart, rangeEnd]) => start >= rangeStart && start < rangeEnd); +} + +function countRemainingRefs( + root: SgNode, + localName: string, + scheduledRanges: Array<[number, number]>, +): number { + return root + .findAll({ rule: { any: [{ kind: "identifier" }, { kind: "shorthand_property_identifier" }] } }) + .filter((node) => node.text() === localName) + .filter( + (node) => !isInsideImportStatement(node) && !isInsideScheduledRange(node, scheduledRanges), + ).length; +} + +function importInsertionIndex(root: SgNode, imports: SgNode[], source: string): number { + const lastImport = imports.at(-1); + if (lastImport) return lastImport.range().end.index; + + if (source.startsWith("#!")) { + const newlineIndex = source.indexOf("\n"); + return newlineIndex === -1 ? source.length : newlineIndex + 1; + } + return ( + root + .children() + .find((child) => child.kind() !== "comment") + ?.range().start.index ?? 0 + ); +} + +function buildAddRuntimeImportEdit(root: SgNode, source: string, imports: SgNode[]): Edit { + return buildAddNamedImportEdit({ + importName: AUTHCONNECTION, + imports, + insertionIndex: importInsertionIndex, + moduleName: RUNTIME_MODULE, + root, + source, + }); +} + +function lineStartIndex(source: string, index: number): number { + let pos = index; + while (pos > 0 && source[pos - 1] !== "\n" && source[pos - 1] !== "\r") pos--; + return pos; +} + +function consumeLineBreak(source: string, index: number): number { + if (source[index] === "\r") return source[index + 1] === "\n" ? index + 2 : index + 1; + if (source[index] === "\n") return index + 1; + return index; +} + +function isHorizontalWhitespace(source: string, start: number, end: number): boolean { + for (let index = start; index < end; index++) { + if (source[index] !== " " && source[index] !== "\t") return false; + } + return true; +} + +function buildImportRemovalEdit(source: string, binding: AuthImport): Edit | null { + const allSpecs = binding.importStmt.findAll({ rule: { kind: "import_specifier" } }); + if (allSpecs.length === 1) { + const range = binding.importStmt.range(); + let end = range.end.index; + if (source[end] === "\n") end++; + return { startPos: range.start.index, endPos: end, insertedText: "" }; + } + + const range = binding.spec.range(); + let start = range.start.index; + let end = range.end.index; + const specEnd = end; + while (end < source.length && /[ \t]/.test(source[end]!)) end++; + if (source[end] === ",") { + end++; + while (end < source.length && /[ \t]/.test(source[end]!)) end++; + const nextLine = consumeLineBreak(source, end); + const lineStart = lineStartIndex(source, start); + if (nextLine !== end && isHorizontalWhitespace(source, lineStart, start)) { + return { startPos: lineStart, endPos: nextLine, insertedText: "" }; + } + return { startPos: start, endPos: end, insertedText: "" }; + } + + const nextLine = consumeLineBreak(source, end); + const lineStart = lineStartIndex(source, start); + if (nextLine !== end && isHorizontalWhitespace(source, lineStart, start)) { + return { startPos: lineStart, endPos: nextLine, insertedText: "" }; + } + + while (start > 0 && /[ \t]/.test(source[start - 1]!)) start--; + if (source[start - 1] === ",") start--; + return { startPos: start, endPos: specEnd, insertedText: "" }; +} + +function applyEdits(source: string, edits: Edit[]): string { + return edits + .toSorted((a, b) => b.startPos - a.startPos || b.endPos - a.endPos) + .reduce( + (current, edit) => + `${current.slice(0, edit.startPos)}${edit.insertedText}${current.slice(edit.endPos)}`, + source, + ) + .replace(/^\n+/, ""); +} + +function transformParsed(source: string, root: SgNode): string | null { + const imports = findImportStatements(root); + const authImports = findAuthImports(imports); + if (authImports.length === 0) return null; + + const authLocalNames = new Set(authImports.map((binding) => binding.localName)); + if (hasAuthLocalCollision(root, authLocalNames)) return null; + + const calls = findDirectAuthCalls(root, authLocalNames); + if (calls.length === 0) return null; + + const existingRuntimeRef = runtimeAuthconnectionReference(imports); + if (!existingRuntimeRef && hasRuntimeImportCollision(root, imports)) return null; + + const runtimeRef = existingRuntimeRef ?? AUTHCONNECTION; + const edits: Edit[] = calls.map((call) => call.objectNode.replace(runtimeRef)); + if (!existingRuntimeRef) edits.push(buildAddRuntimeImportEdit(root, source, imports)); + + const scheduledRangesByLocalName = new Map>(); + for (const call of calls) { + const ranges = scheduledRangesByLocalName.get(call.localName) ?? []; + ranges.push(call.range); + scheduledRangesByLocalName.set(call.localName, ranges); + } + + for (const binding of authImports) { + if (!scheduledRangesByLocalName.has(binding.localName)) continue; + const remainingRefs = countRemainingRefs( + root, + binding.localName, + scheduledRangesByLocalName.get(binding.localName) ?? [], + ); + if (remainingRefs > 0) continue; + const edit = buildImportRemovalEdit(source, binding); + if (edit) edits.push(edit); + } + + const result = applyEdits(source, edits); + return result === source ? null : result; +} + +export default function transform(source: string, filePath: string): string | null { + const root = parseRoot(source, filePath); + return root ? transformParsed(source, root) : null; +} + +function lineForIndex(source: string, index: number): number { + return source.slice(0, index).split(/\r\n|\r|\n/).length; +} + +function excerptForLine(line: string): string { + return line.trim(); +} + +function isReviewLine(excerpt: string): boolean { + if (!excerpt.includes(GET_CONNECTION_TOKEN)) return false; + if ( + excerpt.includes(`${AUTHCONNECTION}.${GET_CONNECTION_TOKEN}`) || + excerpt.includes(`tailor.${AUTHCONNECTION}.${GET_CONNECTION_TOKEN}`) + ) { + return false; + } + return ( + excerpt.includes(`.${GET_CONNECTION_TOKEN}`) || + excerpt.includes(`["${GET_CONNECTION_TOKEN}"]`) || + excerpt.includes(`['${GET_CONNECTION_TOKEN}']`) || + new RegExp(`[,{]\\s*${GET_CONNECTION_TOKEN}\\s*[:}=,]`).test(excerpt) + ); +} + +export function reviewFindings( + source: string, + _filePath: string, + relativePath: string, +): LlmReviewFinding[] { + if (!quickFilter(source)) return []; + + const findings: LlmReviewFinding[] = []; + let offset = 0; + for (const line of source.split(/\n/)) { + const excerpt = excerptForLine(line); + if (isReviewLine(excerpt)) { + findings.push({ + file: relativePath, + line: lineForIndex(source, offset), + message: "Replace defineAuth auth.getConnectionToken() with runtime authconnection.", + excerpt, + }); + } + offset += line.length + 1; + } + return findings; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/expected.ts new file mode 100644 index 000000000..67f3c4f27 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/expected.ts @@ -0,0 +1,6 @@ +import { workflow, authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + await workflow.wait("ready"); + return authconnection.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/input.ts new file mode 100644 index 000000000..2c9a8d975 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/input.ts @@ -0,0 +1,7 @@ +import { workflow } from "@tailor-platform/sdk/runtime"; +import { auth } from "../tailor.config"; + +export async function run() { + await workflow.wait("ready"); + return auth.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/auth-shadowed/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/auth-shadowed/input.ts new file mode 100644 index 000000000..b177f29cd --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/auth-shadowed/input.ts @@ -0,0 +1,5 @@ +import { auth } from "../tailor.config"; + +export async function run(auth: { getConnectionToken(name: string): Promise }) { + return auth.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/bare-arrow-shadowed/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/bare-arrow-shadowed/input.ts new file mode 100644 index 000000000..54de5e380 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/bare-arrow-shadowed/input.ts @@ -0,0 +1,3 @@ +import { auth } from "../tailor.config"; + +export const tokens = ["google"].map(auth => auth.getConnectionToken("google")); diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/expected.ts new file mode 100644 index 000000000..e12e2d964 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/expected.ts @@ -0,0 +1,6 @@ +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const token = await authconnection.getConnectionToken("google"); + return token; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/input.ts new file mode 100644 index 000000000..b5c86587c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/input.ts @@ -0,0 +1,6 @@ +import { auth } from "../tailor.config"; + +export async function run() { + const token = await auth.getConnectionToken("google"); + return token; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/catch-shadowed/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/catch-shadowed/input.ts new file mode 100644 index 000000000..21e6d2f99 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/catch-shadowed/input.ts @@ -0,0 +1,9 @@ +import { auth } from "../tailor.config"; + +export async function run() { + try { + return "ok"; + } catch (auth) { + return auth.getConnectionToken("google"); + } +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/expected.ts new file mode 100644 index 000000000..ef6b7212d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/expected.ts @@ -0,0 +1,5 @@ +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run(token = authconnection) { + return authconnection.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/input.ts new file mode 100644 index 000000000..6cb415f9b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/input.ts @@ -0,0 +1,5 @@ +import { auth } from "../tailor.config"; + +export async function run(token = authconnection) { + return auth.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/expected.ts new file mode 100644 index 000000000..2fd9d3f15 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/expected.ts @@ -0,0 +1,7 @@ +import { auth } from "../tailor.config"; +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const token = await authconnection.getConnectionToken("google"); + return { token, invoker: auth.invoker("manager") }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/input.ts new file mode 100644 index 000000000..d702215b0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/input.ts @@ -0,0 +1,6 @@ +import { auth } from "../tailor.config"; + +export async function run() { + const token = await auth.getConnectionToken("google"); + return { token, invoker: auth.invoker("manager") }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/local-authconnection-collision/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/local-authconnection-collision/input.ts new file mode 100644 index 000000000..e29367f3d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/local-authconnection-collision/input.ts @@ -0,0 +1,7 @@ +import { auth } from "../tailor.config"; + +const authconnection = createClient(); + +export async function run() { + return auth.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/expected.ts new file mode 100644 index 000000000..5898d3f2d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/expected.ts @@ -0,0 +1,11 @@ +import { + other, +} from "../tailor.config"; +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + return { + token: await authconnection.getConnectionToken("google"), + other, + }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/input.ts new file mode 100644 index 000000000..c3a496bc2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/input.ts @@ -0,0 +1,11 @@ +import { + auth, + other, +} from "../tailor.config"; + +export async function run() { + return { + token: await auth.getConnectionToken("google"), + other, + }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/expected.ts new file mode 100644 index 000000000..78370bdda --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/expected.ts @@ -0,0 +1,9 @@ +import { other } from "../tailor.config"; +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + return { + token: await authconnection.getConnectionToken("google"), + other, + }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/input.ts new file mode 100644 index 000000000..7d6a23893 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/input.ts @@ -0,0 +1,8 @@ +import { other, auth } from "../tailor.config"; + +export async function run() { + return { + token: await auth.getConnectionToken("google"), + other, + }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/expected.ts new file mode 100644 index 000000000..ad4c2010e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/expected.ts @@ -0,0 +1,5 @@ +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + return authconnection.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/input.ts new file mode 100644 index 000000000..f2b951056 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/input.ts @@ -0,0 +1,6 @@ +import { type authconnection } from "@tailor-platform/sdk/runtime"; +import { auth } from "../tailor.config"; + +export async function run() { + return auth.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/codemod.yaml b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/codemod.yaml new file mode 100644 index 000000000..412acdd9b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/auth-invoker-call-unwrap" +version: "1.0.0" +description: 'Replace auth.invoker("name") with the bare string "name" while preserving the authInvoker option key' +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/scripts/transform.ts new file mode 100644 index 000000000..236865ec5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/scripts/transform.ts @@ -0,0 +1,5 @@ +import { transformAuthInvoker } from "../../auth-invoker-unwrap/scripts/transform"; + +export default function transform(source: string, filePath: string): string | null { + return transformAuthInvoker(source, filePath, { renameOptionKeys: false }); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/expected.ts new file mode 100644 index 000000000..f4a2e2294 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/expected.ts @@ -0,0 +1,8 @@ +import { db } from "../tailor.config"; + +createResolver({ + name: "orders", + operation: "query", + authInvoker: "kiosk", + body: () => db.table("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/input.ts new file mode 100644 index 000000000..785057c3c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/input.ts @@ -0,0 +1,8 @@ +import { auth, db } from "../tailor.config"; + +createResolver({ + name: "orders", + operation: "query", + authInvoker: auth.invoker("kiosk"), + body: () => db.table("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/key-only/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/key-only/input.ts new file mode 100644 index 000000000..10df309cf --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/key-only/input.ts @@ -0,0 +1,4 @@ +startWorkflow({ + workflow, + authInvoker: "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/codemod.yaml b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/codemod.yaml index fae6d759f..dfbf7f58f 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/codemod.yaml +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/codemod.yaml @@ -1,6 +1,6 @@ name: "@tailor-platform/auth-invoker-unwrap" version: "1.0.0" -description: 'Replace `auth.invoker("name")` with the bare string `"name"` and drop the no-longer-needed `auth` import' +description: 'Rename `authInvoker` options to `invoker`, replace `auth.invoker("name")` with the bare string `"name"`, and drop the no-longer-needed `auth` import' engine: jssg language: typescript since: "1.0.0" diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/scripts/transform.ts index 63bbd413b..1697f182b 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/scripts/transform.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/scripts/transform.ts @@ -1,10 +1,10 @@ import { parse, Lang } from "@ast-grep/napi"; import type { Edit, SgNode } from "@ast-grep/napi"; -const QUICK_FILTER_NEEDLE = "auth.invoker"; +const QUICK_FILTER_NEEDLES = ["auth.invoker", "authInvoker"]; function quickFilter(source: string): boolean { - return source.includes(QUICK_FILTER_NEEDLE); + return QUICK_FILTER_NEEDLES.some((needle) => source.includes(needle)); } function isInsideImportStatement(node: SgNode): boolean { @@ -139,43 +139,179 @@ function findAuthImports(root: SgNode): SgNode[] { }); } +function sameRange(a: SgNode, b: SgNode): boolean { + const ar = a.range(); + const br = b.range(); + return ar.start.index === br.start.index && ar.end.index === br.end.index; +} + +function keyText(node: SgNode | null): string | null { + if (!node) return null; + return node.text().replace(/^['"]|['"]$/g, ""); +} + +function expressionArguments(args: SgNode): SgNode[] { + return args.children().filter((child) => !["(", ")", ","].includes(child.kind() as string)); +} + +function argumentCallForObject(objectNode: SgNode): { call: SgNode; index: number } | null { + const args = objectNode.parent(); + const call = args?.parent(); + if (args?.kind() !== "arguments" || call?.kind() !== "call_expression") return null; + const index = expressionArguments(args).findIndex((arg) => sameRange(arg, objectNode)); + return index === -1 ? null : { call, index }; +} + +function calleeText(call: SgNode): string { + return call.field("function")?.text() ?? ""; +} + +function isCreateCallOptionObject(objectNode: SgNode, functionName: string): boolean { + const callInfo = argumentCallForObject(objectNode); + return ( + callInfo?.index === 0 && + callInfo.call.field("function")?.kind() === "identifier" && + calleeText(callInfo.call) === functionName + ); +} + +function isExecutorOperationObject(objectNode: SgNode): boolean { + const operationPair = objectNode.parent(); + if (operationPair?.kind() !== "pair" || keyText(operationPair.field("key")) !== "operation") { + return false; + } + const configObject = operationPair.parent(); + return ( + configObject?.kind() === "object" && isCreateCallOptionObject(configObject, "createExecutor") + ); +} + +function isSupportedInvokerOptionObject(objectNode: SgNode): boolean { + return ( + isCreateCallOptionObject(objectNode, "createResolver") || + isCreateCallOptionObject(objectNode, "startWorkflow") || + isExecutorOperationObject(objectNode) + ); +} + +function optionObjectForPairKey(node: SgNode): SgNode | null { + const parent = node.parent(); + if (!parent || parent.kind() !== "pair") return null; + const key = parent.field("key"); + if (!key || !sameRange(key, node)) return null; + const objectNode = parent.parent(); + return objectNode?.kind() === "object" ? objectNode : null; +} + +function isSupportedInvokerOptionKey(node: SgNode): boolean { + const objectNode = optionObjectForPairKey(node) ?? node.parent(); + return objectNode?.kind() === "object" && isSupportedInvokerOptionObject(objectNode); +} + +function isSupportedInvokerValueCall(node: SgNode): boolean { + const pair = node.parent(); + if (pair?.kind() !== "pair") return false; + const value = pair.field("value"); + if (!value || !sameRange(value, node)) return false; + const key = keyText(pair.field("key")); + if (key !== "authInvoker" && key !== "invoker") return false; + const objectNode = pair.parent(); + return objectNode?.kind() === "object" && isSupportedInvokerOptionObject(objectNode); +} + +function findAuthInvokerShorthands(root: SgNode): SgNode[] { + return root + .findAll({ + rule: { + kind: "shorthand_property_identifier", + regex: "^authInvoker$", + }, + }) + .filter(isSupportedInvokerOptionKey); +} + +function findAuthInvokerPropertyKeys(root: SgNode): SgNode[] { + return root + .findAll({ + rule: { + kind: "property_identifier", + regex: "^authInvoker$", + }, + }) + .filter(isSupportedInvokerOptionKey); +} + +function findQuotedAuthInvokerPropertyKeys(root: SgNode): SgNode[] { + return root + .findAll({ + rule: { + kind: "string", + regex: "^['\"]authInvoker['\"]$", + }, + }) + .filter(isSupportedInvokerOptionKey); +} + +function renameQuotedKey(node: SgNode): string { + const quote = node.text().startsWith("'") ? "'" : '"'; + return `${quote}invoker${quote}`; +} + +export interface AuthInvokerTransformOptions { + renameOptionKeys?: boolean; +} + /** - * Replace `auth.invoker("name")` calls with the bare `"name"` string literal. + * Replace `auth.invoker("name")` calls with the bare `"name"` string literal + * and optionally rename `authInvoker:` option keys to `invoker:`. * If no other `auth` references remain after the rewrite, drop the `auth` * specifier (or the entire import line when `auth` was its sole specifier). * - * `auth.invoker()` was deprecated in favor of passing the machine user name - * directly; carrying the `auth` import only for `.invoker()` would otherwise - * pull config-layer (Node-only) modules into runtime bundles. + * `auth.invoker()` was removed in favor of passing the machine user name + * directly to `invoker`; carrying the `auth` import only for `.invoker()` + * would otherwise pull config-layer modules into runtime bundles. * @param source - File contents * @param filePath - Absolute path to the file (kept for the runner signature) + * @param options - Transform behavior flags * @returns Transformed source or null when nothing matched. */ -export default function transform(source: string, _filePath: string): string | null { +export function transformAuthInvoker( + source: string, + _filePath: string, + options: AuthInvokerTransformOptions = {}, +): string | null { if (!quickFilter(source)) return null; + const renameOptionKeys = options.renameOptionKeys ?? true; const lang = source.includes("") ? Lang.Tsx : Lang.TypeScript; const root = parse(lang, source).root(); - const calls = findInvokerCalls(root); - if (calls.length === 0) return null; - + const calls = findInvokerCalls(root).filter((c) => isSupportedInvokerValueCall(c.callNode)); const edits: Edit[] = calls.map((c) => c.callNode.replace(c.argText)); + if (renameOptionKeys) { + edits.push(...findAuthInvokerPropertyKeys(root).map((node) => node.replace("invoker"))); + edits.push( + ...findQuotedAuthInvokerPropertyKeys(root).map((node) => node.replace(renameQuotedKey(node))), + ); + edits.push( + ...findAuthInvokerShorthands(root).map((node) => node.replace("invoker: authInvoker")), + ); + } - const remaining = countRemainingAuthRefs( - root, - calls.map((c) => c.range), - ); - if (remaining === 0) { + if ( + calls.length > 0 && + countRemainingAuthRefs( + root, + calls.map((c) => c.range), + ) === 0 + ) { for (const importStmt of findAuthImports(root)) { const edit = buildAuthImportRemovalEdit(source, importStmt); if (edit) edits.push(edit); } } - if (edits.length === 0) return null; - - let result = root.commitEdits(edits); + let result = edits.length === 0 ? source : root.commitEdits(edits); // Normalize: drop the leading blank line that an import removal at the top // of the file leaves behind, and collapse runs of 3+ newlines. @@ -183,3 +319,7 @@ export default function transform(source: string, _filePath: string): string | n return result === source ? null : result; } + +export default function transform(source: string, filePath: string): string | null { + return transformAuthInvoker(source, filePath); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/basic-resolver/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/basic-resolver/expected.ts deleted file mode 100644 index 70ea71c4b..000000000 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/basic-resolver/expected.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createResolver, t } from "@tailor-platform/sdk"; -import orderProcessingWorkflow from "../workflows/order-processing"; - -export default createResolver({ - name: "triggerWorkflow", - type: "Mutation", - input: { - orderId: t.string().description("Order ID"), - customerId: t.string().description("Customer ID for the order"), - }, - body: async ({ input }) => { - const workflowRunId = await orderProcessingWorkflow.trigger( - { - orderId: input.orderId, - customerId: input.customerId, - }, - { authInvoker: "manager-machine-user" }, - ); - return workflowRunId; - }, -}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/expected.ts new file mode 100644 index 000000000..422239484 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/expected.ts @@ -0,0 +1,10 @@ +export interface Options { + authInvoker?: string; +} + +startWorkflow({ + workflow, + message: "authInvoker: keep this string", + // authInvoker: keep this comment + invoker: "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/input.ts new file mode 100644 index 000000000..1254b3554 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/input.ts @@ -0,0 +1,10 @@ +export interface Options { + authInvoker?: string; +} + +startWorkflow({ + workflow, + message: "authInvoker: keep this string", + // authInvoker: keep this comment + authInvoker: "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/expected.ts index be4be98b8..717436393 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/expected.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/expected.ts @@ -1,6 +1,8 @@ import { db } from "../tailor.config"; -export const cfg = { - authInvoker: "kiosk", - table: db.type("Order"), -}; +createResolver({ + name: "orders", + operation: "query", + invoker: "kiosk", + body: () => db.table("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/input.ts index b9f79afd2..785057c3c 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/input.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/input.ts @@ -1,6 +1,8 @@ import { auth, db } from "../tailor.config"; -export const cfg = { +createResolver({ + name: "orders", + operation: "query", authInvoker: auth.invoker("kiosk"), - table: db.type("Order"), -}; + body: () => db.table("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/expected.ts index 9db289de7..864acf8ce 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/expected.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/expected.ts @@ -1,8 +1,10 @@ import { auth, db } from "../tailor.config"; -export const cfg = { - authInvoker: "kiosk", +createResolver({ + name: "orders", + operation: "query", + invoker: "kiosk", // `auth` is still referenced below, so the import must be preserved. ownerType: auth.machineUser, - table: db.type("Order"), -}; + body: () => db.table("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/input.ts index 2381e1fd6..7fdee6353 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/input.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/input.ts @@ -1,8 +1,10 @@ import { auth, db } from "../tailor.config"; -export const cfg = { +createResolver({ + name: "orders", + operation: "query", authInvoker: auth.invoker("kiosk"), // `auth` is still referenced below, so the import must be preserved. ownerType: auth.machineUser, - table: db.type("Order"), -}; + body: () => db.table("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/expected.ts new file mode 100644 index 000000000..0e98f3a86 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/expected.ts @@ -0,0 +1,10 @@ +import { auth } from "../tailor.config"; + +const machineUserName = "kiosk"; + +startWorkflow({ + workflow, + // The argument is not a literal string, so the call is left intact and the + // `auth` import stays. + invoker: auth.invoker(machineUserName), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/input.ts index feee54fe2..bb14e5378 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/input.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/input.ts @@ -2,8 +2,9 @@ import { auth } from "../tailor.config"; const machineUserName = "kiosk"; -export const cfg = { +startWorkflow({ + workflow, // The argument is not a literal string, so the call is left intact and the // `auth` import stays. authInvoker: auth.invoker(machineUserName), -}; +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/expected.ts new file mode 100644 index 000000000..0cbc9b716 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/expected.ts @@ -0,0 +1,4 @@ +startWorkflow({ + workflow, + invoker: "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/input.ts new file mode 100644 index 000000000..10df309cf --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/input.ts @@ -0,0 +1,4 @@ +startWorkflow({ + workflow, + authInvoker: "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/platform-payload-untouched/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/platform-payload-untouched/input.ts new file mode 100644 index 000000000..50dafc3f0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/platform-payload-untouched/input.ts @@ -0,0 +1,17 @@ +import { auth } from "../tailor.config"; + +tailor.workflow.triggerWorkflow("daily", {}, { + authInvoker: { namespace, machineUserName }, +}); + +tailor.workflow.triggerWorkflow("daily", {}, { + authInvoker: auth.invoker("kiosk"), +}); + +paymentGateway.trigger("charge", { + authInvoker: "secret", +}); + +paymentGateway.trigger("charge", { + authInvoker: "secret", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/expected.ts new file mode 100644 index 000000000..a9b8b9ca9 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/expected.ts @@ -0,0 +1,4 @@ +startWorkflow({ + workflow, + "invoker": "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/input.ts new file mode 100644 index 000000000..3be988273 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/input.ts @@ -0,0 +1,4 @@ +startWorkflow({ + workflow, + "authInvoker": "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/expected.ts new file mode 100644 index 000000000..b91bf847d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/expected.ts @@ -0,0 +1,6 @@ +const authInvoker = "kiosk"; + +startWorkflow({ + workflow, + invoker: authInvoker, +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/input.ts new file mode 100644 index 000000000..39b2e41f8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/input.ts @@ -0,0 +1,6 @@ +const authInvoker = "kiosk"; + +startWorkflow({ + workflow, + authInvoker, +}); diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/cli-rename/scripts/transform.ts index fbe72152c..7a498fef4 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/scripts/transform.ts +++ b/packages/sdk-codemod/codemods/v2/cli-rename/scripts/transform.ts @@ -2,26 +2,503 @@ import * as path from "pathe"; // Map of v1 multi-word command names to their v2 single-word replacements. const COMMAND_RENAMES: ReadonlyArray = [["crash-report", "crashreport"]]; +const OPTION_RENAMES: ReadonlyArray = [ + ["--machineuser", "--machine-user"], +]; +const ARG_VALUE = `(?:[^\\s'"\`;&|]+|'[^']*'|"(?:(?:\\\\.)|[^"\\\\])*")`; +const BOOLEAN_GLOBAL_ARG = "(?:--verbose|--json|-j)"; +const VALUE_GLOBAL_ARG = "(?:--env-file|--env-file-if-exists|-e)"; +const GLOBAL_ARG_PATTERN = `(?:(?:\\s+${BOOLEAN_GLOBAL_ARG})|(?:\\s+${VALUE_GLOBAL_ARG}(?:=${ARG_VALUE}|\\s+${ARG_VALUE})))*`; +const TAILOR_BINARY = `(? from).join("|")})\\b`, + `${TAILOR_BINARY}(${GLOBAL_ARG_PATTERN}\\s+)(${COMMAND_RENAMES.map(([from]) => from).join("|")})\\b`, "g", ); +const TAILOR_BINARY_PATTERN = new RegExp(TAILOR_BINARY, "g"); const COMMAND_MAP = new Map(COMMAND_RENAMES); -function replaceAll(value: string): string { - return value.replace( +interface TextRange { + start: number; + end: number; +} + +interface ActiveQuote { + char: "'" | '"'; + escaped: boolean; +} + +function isOptionBoundaryChar(value: string | undefined): boolean { + return value === undefined || !/[\w-]/.test(value); +} + +function findInlineCodeSpanEnd(source: string, start: number): number | undefined { + const lineStart = source.lastIndexOf("\n", start - 1) + 1; + const ticksBefore = [...source.slice(lineStart, start).matchAll(/`/g)].length; + if (ticksBefore % 2 === 0) return undefined; + + const codeSpanEnd = source.indexOf("`", start); + return codeSpanEnd === -1 ? undefined : codeSpanEnd; +} + +function findEnclosingLineQuoteEnd(source: string, start: number): number | undefined { + const lineStart = source.lastIndexOf("\n", start - 1) + 1; + const lineEnd = source.indexOf("\n", start); + const limit = lineEnd === -1 ? source.length : lineEnd; + let quote: "'" | '"' | null = null; + + for (let index = lineStart; index < start; index += 1) { + const ch = source[index]; + if (quote !== null) { + if (ch === "\\" && quote === '"' && index + 1 < start) { + index += 1; + continue; + } + if (ch === quote) { + quote = null; + } + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + } + } + + if (quote === null) return undefined; + + for (let index = start; index < limit; index += 1) { + const ch = source[index]; + if (ch === "\\" && quote === '"' && index + 1 < limit) { + index += 1; + continue; + } + if (ch === quote) { + return index; + } + } + + return undefined; +} + +function lineIndent(line: string): number { + const match = line.match(/^ */); + return match?.[0].length ?? 0; +} + +function isFoldedScalarHeader(line: string): boolean { + return /^\s*(?:-\s*)?[^#\n]*:\s*>[+-]?(?:\s*(?:#.*)?)?$/.test(line); +} + +function findFoldedYamlRanges(source: string): TextRange[] { + const ranges: TextRange[] = []; + const lines = source.match(/^.*(?:\n|$)/gm) ?? []; + let offset = 0; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const body = line.replace(/\r?\n$/, ""); + offset += line.length; + + if (!isFoldedScalarHeader(body)) continue; + + const baseIndent = lineIndent(body); + let rangeStart: number | undefined; + let rangeEnd: number | undefined; + let cursor = offset; + + for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) { + const nextLine = lines[nextIndex]; + const nextBody = nextLine.replace(/\r?\n$/, ""); + const trimmed = nextBody.trim(); + const indent = lineIndent(nextBody); + + if (trimmed !== "" && indent <= baseIndent) break; + + if (trimmed === "") { + if (rangeStart !== undefined && rangeEnd !== undefined) { + ranges.push({ start: rangeStart, end: rangeEnd }); + rangeStart = undefined; + rangeEnd = undefined; + } + } else { + rangeStart ??= cursor; + rangeEnd = cursor + nextLine.length; + } + cursor += nextLine.length; + } + + if (rangeStart !== undefined && rangeEnd !== undefined) { + ranges.push({ start: rangeStart, end: rangeEnd }); + } + } + + return ranges; +} + +function findMarkdownFencedCodeRanges(source: string): TextRange[] { + const ranges: TextRange[] = []; + const lines = source.match(/^.*(?:\n|$)/gm) ?? []; + let offset = 0; + let open: { char: string; length: number; start: number } | undefined; + + for (const line of lines) { + const body = line.replace(/\r?\n$/, ""); + + if (open) { + const close = body.match(/^ {0,3}(`{3,}|~{3,})\s*$/); + const marker = close?.[1]; + if (marker && marker[0] === open.char && marker.length >= open.length) { + ranges.push({ start: open.start, end: offset }); + open = undefined; + } + } else { + const start = body.match(/^ {0,3}(`{3,}|~{3,}).*$/); + const marker = start?.[1]; + if (marker) { + open = { char: marker[0], length: marker.length, start: offset + line.length }; + } + } + + offset += line.length; + } + + if (open) { + ranges.push({ start: open.start, end: source.length }); + } + + return ranges; +} + +function isCommandSeparator(source: string, index: number): boolean { + const ch = source[index]; + const prev = source[index - 1]; + if (prev === "\\") return false; + + if (ch === "&") { + const next = source[index + 1]; + if (prev === ">" || prev === "<" || next === ">") return false; + } + + return ch === ";" || ch === "&" || ch === "|"; +} + +function startsCommandSubstitution(source: string, index: number): boolean { + return source[index] === "$" && source[index + 1] === "(" && source[index - 1] !== "\\"; +} + +function findCommandSubstitutionEnd(source: string, start: number): number | undefined { + let depth = 1; + let index = start + 2; + let quote: ActiveQuote | null = null; + + while (index < source.length) { + const ch = source[index]; + + if (quote !== null) { + if (quote.escaped) { + if (ch === "\\" && source[index + 1] === quote.char) { + quote = null; + index += 2; + continue; + } + index += 1; + continue; + } + if (quote.char === '"' && startsCommandSubstitution(source, index)) { + depth += 1; + index += 2; + continue; + } + if (ch === "\\" && quote.char === '"' && index + 1 < source.length) { + index += 2; + continue; + } + if (ch === quote.char) { + quote = null; + } + index += 1; + continue; + } + + if (ch === "\\" && source[index + 1] === '"') { + quote = { char: '"', escaped: true }; + index += 2; + continue; + } + + if (ch === "'" || ch === '"') { + quote = { char: ch, escaped: false }; + index += 1; + continue; + } + + if (startsCommandSubstitution(source, index)) { + depth += 1; + index += 2; + continue; + } + + if (ch === ")") { + depth -= 1; + if (depth === 0) return index; + } + + index += 1; + } + + return undefined; +} + +function findContainingRange( + ranges: TextRange[] | undefined, + index: number, +): TextRange | undefined { + return ranges?.find((range) => range.start <= index && index < range.end); +} + +function findTailorCommandEnd( + source: string, + start: number, + foldedYamlRanges?: TextRange[], + markdownFencedCodeRanges?: TextRange[], +): number { + const inlineCodeSpanEnd = findInlineCodeSpanEnd(source, start); + const enclosingLineQuoteEnd = findEnclosingLineQuoteEnd(source, start); + const limit = Math.min( + inlineCodeSpanEnd ?? source.length, + enclosingLineQuoteEnd ?? source.length, + ); + const foldedYamlRange = findContainingRange(foldedYamlRanges, start); + const markdownFencedCodeRange = findContainingRange(markdownFencedCodeRanges, start); + const delimitedRange = foldedYamlRange ?? markdownFencedCodeRange; + const commandLimit = delimitedRange ? Math.min(limit, delimitedRange.end) : limit; + let quote: ActiveQuote | null = null; + let end = start; + + while (end < commandLimit) { + const ch = source[end]; + + if (quote !== null) { + if (quote.escaped) { + if (ch === "\\" && source[end + 1] === quote.char) { + quote = null; + end += 2; + continue; + } + end += 1; + continue; + } + if (quote.char === '"' && startsCommandSubstitution(source, end)) { + const substitutionEnd = findCommandSubstitutionEnd(source, end); + if (substitutionEnd !== undefined) { + end = substitutionEnd + 1; + continue; + } + } + if (ch === "\\" && quote.char === '"' && end + 1 < commandLimit) { + end += 2; + continue; + } + if (ch === quote.char) { + quote = null; + } + end += 1; + continue; + } + + if (ch === "\\" && source[end + 1] === '"') { + quote = { char: '"', escaped: true }; + end += 2; + continue; + } + + if (ch === "'" || ch === '"') { + quote = { char: ch, escaped: false }; + end += 1; + continue; + } + + if (startsCommandSubstitution(source, end)) { + const substitutionEnd = findCommandSubstitutionEnd(source, end); + if (substitutionEnd !== undefined) { + end = substitutionEnd + 1; + continue; + } + } + + const prev = source[end - 1]; + if (ch === ")") break; + if (isCommandSeparator(source, end)) break; + if (ch === "\n" && prev !== "\\" && !foldedYamlRange) break; + end += 1; + } + return end; +} + +function isDelimitedCommandContext( + source: string, + start: number, + foldedYamlRanges?: TextRange[], + markdownFencedCodeRanges?: TextRange[], +): boolean { + return ( + findInlineCodeSpanEnd(source, start) !== undefined || + findEnclosingLineQuoteEnd(source, start) !== undefined || + findContainingRange(foldedYamlRanges, start) !== undefined || + findContainingRange(markdownFencedCodeRanges, start) !== undefined + ); +} + +function findOptionRename(command: string, index: number): readonly [string, string] | undefined { + return OPTION_RENAMES.find( + ([from]) => + command.startsWith(from, index) && + isOptionBoundaryChar(command[index - 1]) && + isOptionBoundaryChar(command[index + from.length]), + ); +} + +function replaceOptionsInCommand(command: string): string { + let updated = ""; + let index = 0; + let quote: ActiveQuote | null = null; + + while (index < command.length) { + const ch = command[index]; + + if (quote !== null) { + if (quote.char === '"' && startsCommandSubstitution(command, index)) { + const substitutionEnd = findCommandSubstitutionEnd(command, index); + if (substitutionEnd !== undefined) { + updated += "$("; + updated += replaceAll(command.slice(index + 2, substitutionEnd)); + updated += ")"; + index = substitutionEnd + 1; + continue; + } + } + updated += ch; + if (quote.escaped) { + if (ch === "\\" && command[index + 1] === quote.char) { + index += 1; + updated += command[index]; + quote = null; + } + } else if (ch === "\\" && quote.char === '"' && index + 1 < command.length) { + index += 1; + updated += command[index]; + } else if (ch === quote.char) { + quote = null; + } + index += 1; + continue; + } + + if (ch === "\\" && command[index + 1] === '"') { + quote = { char: '"', escaped: true }; + updated += ch; + index += 1; + updated += command[index]; + index += 1; + continue; + } + + if (ch === "'" || ch === '"') { + quote = { char: ch, escaped: false }; + updated += ch; + index += 1; + continue; + } + + if (startsCommandSubstitution(command, index)) { + const substitutionEnd = findCommandSubstitutionEnd(command, index); + if (substitutionEnd !== undefined) { + updated += "$("; + updated += replaceAll(command.slice(index + 2, substitutionEnd)); + updated += ")"; + index = substitutionEnd + 1; + continue; + } + } + + const rename = findOptionRename(command, index); + if (rename) { + updated += rename[1]; + index += rename[0].length; + continue; + } + + updated += ch; + index += 1; + } + + return updated; +} + +function replaceOptionsInTailorCommands( + source: string, + foldedYamlRanges?: TextRange[], + requireDelimitedContext = false, + markdownFencedCodeRanges?: TextRange[], +): string { + let updated = ""; + let cursor = 0; + TAILOR_BINARY_PATTERN.lastIndex = 0; + + for (;;) { + const match = TAILOR_BINARY_PATTERN.exec(source); + if (!match) break; + + const start = match.index; + if (start < cursor) continue; + + if ( + requireDelimitedContext && + !isDelimitedCommandContext(source, start, foldedYamlRanges, markdownFencedCodeRanges) + ) { + TAILOR_BINARY_PATTERN.lastIndex = start + match[0].length; + continue; + } + + const end = findTailorCommandEnd(source, start, foldedYamlRanges, markdownFencedCodeRanges); + updated += source.slice(cursor, start); + updated += replaceOptionsInCommand(source.slice(start, end)); + cursor = end; + TAILOR_BINARY_PATTERN.lastIndex = end; + } + + return updated + source.slice(cursor); +} + +function replaceAll( + value: string, + parseFoldedYaml = false, + requireDelimitedContext = false, + parseMarkdownFencedCode = false, +): string { + const updated = value.replace( COMMAND_PATTERN, - (_match, ver: string | undefined, sep: string, cmd: string) => - `tailor-sdk${ver ?? ""}${sep}${COMMAND_MAP.get(cmd) ?? cmd}`, + (match, _prefix: string, cmd: string) => + `${match.slice(0, -cmd.length)}${COMMAND_MAP.get(cmd) ?? cmd}`, + ); + const foldedYamlRanges = parseFoldedYaml ? findFoldedYamlRanges(updated) : undefined; + const markdownFencedCodeRanges = parseMarkdownFencedCode + ? findMarkdownFencedCodeRanges(updated) + : undefined; + return replaceOptionsInTailorCommands( + updated, + foldedYamlRanges, + requireDelimitedContext, + markdownFencedCodeRanges, ); } -function transformText(source: string): string | null { - if (!COMMAND_PATTERN.test(source)) return null; - COMMAND_PATTERN.lastIndex = 0; - const updated = replaceAll(source); +function transformText(source: string, filePath: string): string | null { + const ext = path.extname(filePath).toLowerCase(); + const isYaml = ext === ".yml" || ext === ".yaml"; + const isMarkdown = ext === ".md"; + const updated = replaceAll(source, isYaml, isMarkdown, isMarkdown); return updated === source ? null : updated; } @@ -53,13 +530,10 @@ function transformPackageJson(source: string): string | null { /** * Apply v2 CLI naming conventions: multi-word commands collapse into a single - * word (`crash-report` → `crashreport`). Optional `@version` pins on the binary - * (`tailor-sdk@latest`) are preserved. + * word (`crash-report` → `crashreport`), and legacy option spellings are + * rewritten to kebab-case (`--machineuser` → `--machine-user`). Optional + * `@version` pins on the binary (`tailor-sdk@latest`) are preserved. * - * Long options (`--executionId`, `--executorName`, `--jobId`) and the - * positional argument keys with the same names are intentionally not rewritten: - * those tokens are positional in the SDK CLI and never appear as long flags in - * user scripts, so a transform here would have no real-world target. * @param source - File contents * @param filePath - Absolute path to the file (used to dispatch package.json vs text) * @returns Transformed source or null when nothing matched. @@ -67,5 +541,5 @@ function transformPackageJson(source: string): string | null { export default function transform(source: string, filePath: string): string | null { const ext = path.extname(filePath).toLowerCase(); if (ext === ".json") return transformPackageJson(source); - return transformText(source); + return transformText(source, filePath); } diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/expected.md b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/expected.md new file mode 100644 index 000000000..9419f992e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/expected.md @@ -0,0 +1,16 @@ +# CLI migration + +Use `tailor-sdk login --machine-user` before running `tailor-sdk query --machine-user=ci`. + +Use `tailor-sdk --json crashreport list` but leave `other-cli --machineuser=ci` alone. + +Use `pnpm exec tailor-sdk login --machine-user` but leave `other-cli --machineuser=ci` alone. + +```sh +tailor-sdk login --machine-user +tailor-sdk query --machine-user=ci +``` + +Use tailor-sdk login --machineuser before running other-cli --machineuser=ci. + +Do not rewrite unrelated commands such as `other-cli --machineuser=ci`. diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/input.md b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/input.md new file mode 100644 index 000000000..e68df44b3 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/input.md @@ -0,0 +1,16 @@ +# CLI migration + +Use `tailor-sdk login --machineuser` before running `tailor-sdk query --machineuser=ci`. + +Use `tailor-sdk --json crash-report list` but leave `other-cli --machineuser=ci` alone. + +Use `pnpm exec tailor-sdk login --machineuser` but leave `other-cli --machineuser=ci` alone. + +```sh +tailor-sdk login --machineuser +tailor-sdk query --machineuser=ci +``` + +Use tailor-sdk login --machineuser before running other-cli --machineuser=ci. + +Do not rewrite unrelated commands such as `other-cli --machineuser=ci`. diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/expected.json b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/expected.json index c8a36feda..0958528e7 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/expected.json +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/expected.json @@ -4,6 +4,9 @@ "scripts": { "report:tail": "tailor-sdk crashreport list", "report:send": "tailor-sdk crashreport send --file ./latest.crash.log", + "report:json": "tailor-sdk --json crashreport list", + "login": "tailor-sdk login --machine-user ci", + "query": "tailor-sdk query --machine-user=ci --json", "build": "tsc" } } diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/input.json b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/input.json index fe43c4b44..92525985a 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/input.json +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/input.json @@ -4,6 +4,9 @@ "scripts": { "report:tail": "tailor-sdk crash-report list", "report:send": "tailor-sdk crash-report send --file ./latest.crash.log", + "report:json": "tailor-sdk --json crash-report list", + "login": "tailor-sdk login --machineuser ci", + "query": "tailor-sdk query --machineuser=ci --json", "build": "tsc" } } diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/expected.sh b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/expected.sh index 274b44990..faba9713c 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/expected.sh +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/expected.sh @@ -3,3 +3,13 @@ set -euo pipefail pnpm exec tailor-sdk crashreport list pnpm exec tailor-sdk crashreport send --file ./latest.crash.log +pnpm exec tailor-sdk workflow start approval --machine-user ci +tailor-sdk query --query 'select 1' --machine-user ci +tailor-sdk query 2>&1 --machine-user ci +tailor-sdk query $(build-query --machineuser=ci) --machine-user ci +tailor-sdk query --query 'select 1;' --machine-user ci +tailor-sdk query --query "select 1 | 2" --machine-user ci +tailor-sdk workflow start approval --arg '{"ok":true}' \ + --machine-user ci +tailor-sdk --json crashreport list +TOKEN=$(tailor-sdk query --machine-user ci) other-cli --machineuser=ci diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/input.sh b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/input.sh index 6202aa2aa..5b1573060 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/input.sh +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/input.sh @@ -3,3 +3,13 @@ set -euo pipefail pnpm exec tailor-sdk crash-report list pnpm exec tailor-sdk crash-report send --file ./latest.crash.log +pnpm exec tailor-sdk workflow start approval --machineuser ci +tailor-sdk query --query 'select 1' --machineuser ci +tailor-sdk query 2>&1 --machineuser ci +tailor-sdk query $(build-query --machineuser=ci) --machineuser ci +tailor-sdk query --query 'select 1;' --machineuser ci +tailor-sdk query --query "select 1 | 2" --machineuser ci +tailor-sdk workflow start approval --arg '{"ok":true}' \ + --machineuser ci +tailor-sdk --json crash-report list +TOKEN=$(tailor-sdk query --machineuser ci) other-cli --machineuser=ci diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/expected.yml b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/expected.yml index 0cddd575f..af33e2c9b 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/expected.yml +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/expected.yml @@ -8,3 +8,10 @@ jobs: - run: pnpm install - run: pnpm exec tailor-sdk crashreport list - run: pnpm exec tailor-sdk crashreport send --file latest.crash.log + - run: pnpm exec tailor-sdk login --machine-user ci + - run: > + pnpm exec tailor-sdk login + --machine-user ci + - run: "pnpm exec tailor-sdk query --machine-user ci" + - run: "tailor-sdk query --query \"select 1\" --machine-user ci" + - run: pnpm exec tailor-sdk workflow start approval --machine-user ci diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/input.yml b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/input.yml index 83ab2b5f5..eebb22e0b 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/input.yml +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/input.yml @@ -8,3 +8,10 @@ jobs: - run: pnpm install - run: pnpm exec tailor-sdk crash-report list - run: pnpm exec tailor-sdk crash-report send --file latest.crash.log + - run: pnpm exec tailor-sdk login --machineuser ci + - run: > + pnpm exec tailor-sdk login + --machineuser ci + - run: "pnpm exec tailor-sdk query --machineuser ci" + - run: "tailor-sdk query --query \"select 1\" --machineuser ci" + - run: pnpm exec tailor-sdk workflow start approval --machineuser ci diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/no-match/input.sh b/packages/sdk-codemod/codemods/v2/cli-rename/tests/no-match/input.sh index a17ea544a..4ad7faf25 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/no-match/input.sh +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/no-match/input.sh @@ -3,8 +3,20 @@ set -euo pipefail # Should not match: command name is part of a longer word pnpm exec tailor-sdk crash-reporter list +tailor-sdk-wrapper crash-report list +my-tailor-sdk crash-report list # Should not match: bare crash-report not preceded by tailor-sdk echo "Generated crash-report uploaded" # Should not match: positional/long-form camelCase identifiers are out of scope pnpm exec tailor-sdk function logs --executionId abc pnpm exec tailor-sdk executor jobs my-executor --jobId xyz +# Should not match: longer option names +pnpm exec tailor-sdk login --machineusername ci +# Should not match: same option spelling for another command +other-cli --machineuser=ci +tailor-sdk-wrapper --machineuser ci +my-tailor-sdk --machineuser ci +# Should not match: same option spelling after a Tailor command substitution +TOKEN=$(tailor-sdk machineuser token ci) other-cli --machineuser=ci +# Should not match: option spelling inside quoted arguments +tailor-sdk query --query 'select --machineuser' diff --git a/packages/sdk-codemod/codemods/v2/db-type-to-table/codemod.yaml b/packages/sdk-codemod/codemods/v2/db-type-to-table/codemod.yaml new file mode 100644 index 000000000..8635334f1 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/db-type-to-table/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/db-type-to-table" +version: "1.0.0" +description: "Rename TailorDB schema builder calls from db.type() to db.table()" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/db-type-to-table/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/db-type-to-table/scripts/transform.ts new file mode 100644 index 000000000..b1a5fa87f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/db-type-to-table/scripts/transform.ts @@ -0,0 +1,568 @@ +import { parse, Lang } from "@ast-grep/napi"; +import { + findImportStatements, + importBindings, + importSource, +} from "../../../../src/ast-grep-helpers"; +import type { LlmReviewFinding } from "../../../../src/types"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const SDK_MODULE = "@tailor-platform/sdk"; + +function sourceLang(filePath: string, source: string): Lang { + return filePath.endsWith(".tsx") || filePath.endsWith(".jsx") || source.includes(" node.children().filter((child) => child.kind() === "identifier")) + .map((node) => node.text()); +} + +function isInsideImportStatement(node: SgNode): boolean { + let current = node.parent(); + while (current) { + if (current.kind() === "import_statement") return true; + current = current.parent(); + } + return false; +} + +function isBindingLeafKind(kind: ReturnType): boolean { + return kind === "identifier" || kind === "shorthand_property_identifier_pattern"; +} + +function isBindingPatternKind(kind: ReturnType): boolean { + return ( + isBindingLeafKind(kind) || + kind === "object_pattern" || + kind === "array_pattern" || + kind === "rest_pattern" + ); +} + +function collectBindingNodes(node: SgNode, names: Set, result: SgNode[]): void { + if (isBindingLeafKind(node.kind())) { + if (names.has(node.text())) result.push(node); + return; + } + + for (const child of node.children()) { + if (child.kind() === "property_identifier") continue; + if (child.kind() === "=") break; + collectBindingNodes(child, names, result); + } +} + +function bindingNodes(node: SgNode, names: Set): SgNode[] { + const result: SgNode[] = []; + collectBindingNodes(node, names, result); + return result; +} + +function directBindingNodes(node: SgNode, names: Set): SgNode[] { + const result: SgNode[] = []; + for (const child of node.children()) { + if (child.kind() === "=") break; + if (isBindingPatternKind(child.kind())) collectBindingNodes(child, names, result); + } + return result; +} + +function firstDeclaratorChild(node: SgNode): SgNode | null { + return node.children().find((child) => child.kind() !== "=") ?? null; +} + +function declaratorValue(node: SgNode): SgNode | null { + const children = node.children(); + const equalsIndex = children.findIndex((child) => child.kind() === "="); + if (equalsIndex === -1) return null; + return children.slice(equalsIndex + 1).find((child) => child.kind() !== "comment") ?? null; +} + +function assignmentTarget(node: SgNode): SgNode | null { + const children = node.children(); + const equalsIndex = children.findIndex((child) => child.kind() === "="); + if (equalsIndex === -1) return null; + return children.slice(0, equalsIndex).find((child) => child.kind() !== "comment") ?? null; +} + +function assignmentValue(node: SgNode): SgNode | null { + const children = node.children(); + const equalsIndex = children.findIndex((child) => child.kind() === "="); + if (equalsIndex === -1) return null; + return children.slice(equalsIndex + 1).find((child) => child.kind() !== "comment") ?? null; +} + +function parameterDefaultTarget(node: SgNode): SgNode | null { + const children = node.children(); + const equalsIndex = children.findIndex((child) => child.kind() === "="); + if (equalsIndex === -1) return null; + return children.slice(0, equalsIndex).find((child) => isBindingPatternKind(child.kind())) ?? null; +} + +function parameterDefaultValue(node: SgNode): SgNode | null { + const children = node.children(); + const equalsIndex = children.findIndex((child) => child.kind() === "="); + if (equalsIndex === -1) return null; + return children.slice(equalsIndex + 1).find((child) => child.kind() !== "comment") ?? null; +} + +function addShadowedRange( + shadowedRanges: Map>, + name: string, + scopeNode: SgNode, +) { + const range = scopeNode.range(); + if (!shadowedRanges.has(name)) shadowedRanges.set(name, []); + shadowedRanges.get(name)!.push({ start: range.start.index, end: range.end.index }); +} + +function nearestScope(node: SgNode): SgNode { + let current: SgNode | null = node.parent(); + while (current) { + const kind = current.kind(); + if ( + kind === "statement_block" || + kind === "program" || + kind === "switch_body" || + kind === "for_statement" || + kind === "for_in_statement" + ) { + return current; + } + current = current.parent(); + } + return node; +} + +function functionScope(node: SgNode): SgNode { + let current: SgNode | null = node.parent(); + while (current) { + const kind = current.kind(); + if ( + kind === "function_declaration" || + kind === "function_expression" || + kind === "arrow_function" || + kind === "method_definition" || + kind === "program" + ) { + return current; + } + current = current.parent(); + } + return node; +} + +function variableDeclarationScope(node: SgNode): SgNode { + const declaration = node.parent(); + if (/^var\b/.test(declaration?.text().trimStart() ?? "")) return functionScope(node); + return nearestScope(node); +} + +function parameterScope(node: SgNode): SgNode { + let current: SgNode | null = node.parent(); + while (current) { + const kind = current.kind(); + if (kind === "formal_parameters") { + current = current.parent(); + continue; + } + if ( + kind === "function_declaration" || + kind === "function_expression" || + kind === "arrow_function" || + kind === "method_definition" + ) { + return current; + } + break; + } + return nearestScope(node); +} + +function buildShadowedRanges(root: SgNode, names: Set) { + const shadowedRanges = new Map>(); + + for (const decl of root.findAll({ rule: { kind: "variable_declarator" } })) { + if (isInsideImportStatement(decl)) continue; + const binding = firstDeclaratorChild(decl); + if (!binding) continue; + for (const name of bindingNodes(binding, names)) { + addShadowedRange(shadowedRanges, name.text(), variableDeclarationScope(decl)); + } + } + + for (const decl of root.findAll({ + rule: { + any: [ + { kind: "function_declaration" }, + { kind: "class_declaration" }, + { kind: "enum_declaration" }, + ], + }, + })) { + const name = decl + .children() + .find((child) => child.kind() === "identifier" && names.has(child.text())); + if (name) addShadowedRange(shadowedRanges, name.text(), nearestScope(decl)); + } + + for (const param of root.findAll({ + rule: { any: [{ kind: "required_parameter" }, { kind: "optional_parameter" }] }, + })) { + for (const name of directBindingNodes(param, names)) { + addShadowedRange(shadowedRanges, name.text(), parameterScope(param)); + } + } + + for (const arrow of root.findAll({ rule: { kind: "arrow_function" } })) { + const children = arrow.children(); + const arrowIndex = children.findIndex((child) => child.kind() === "=>"); + if (arrowIndex === -1) continue; + for (const child of children.slice(0, arrowIndex)) { + if (child.kind() === "=") break; + if (!isBindingPatternKind(child.kind())) continue; + for (const name of bindingNodes(child, names)) { + addShadowedRange(shadowedRanges, name.text(), arrow); + } + } + } + + for (const catchClause of root.findAll({ rule: { kind: "catch_clause" } })) { + for (const name of directBindingNodes(catchClause, names)) { + addShadowedRange(shadowedRanges, name.text(), catchClause); + } + } + + for (const loop of root.findAll({ rule: { kind: "for_in_statement" } })) { + const children = loop.children(); + const keywordIndex = children.findIndex( + (child) => child.kind() === "in" || child.kind() === "of", + ); + if (keywordIndex === -1) continue; + for (const child of children.slice(0, keywordIndex)) { + for (const name of bindingNodes(child, names)) { + addShadowedRange(shadowedRanges, name.text(), loop); + } + } + } + + return shadowedRanges; +} + +function isShadowed( + node: SgNode, + shadowedRanges: Map>, +): boolean { + const ranges = shadowedRanges.get(node.text()); + if (!ranges) return false; + const position = node.range().start.index; + return ranges.some((range) => position >= range.start && position < range.end); +} + +function unwrapExpression(node: SgNode | null): SgNode | null { + let current = node; + while (current) { + const kind = current.kind(); + if (kind === "parenthesized_expression") { + current = + current.children().find((child) => child.kind() !== "(" && child.kind() !== ")") ?? null; + continue; + } + if ( + kind === "as_expression" || + kind === "satisfies_expression" || + kind === "non_null_expression" + ) { + current = current.children()[0] ?? null; + continue; + } + if (kind === "type_assertion") { + current = current.children().find((child) => child.kind() !== "type_arguments") ?? null; + continue; + } + return current; + } + return null; +} + +function isSdkDbMember( + object: SgNode | null, + dbNames: Set, + namespaceNames: Set, + shadowedRanges: Map>, +) { + const unwrapped = unwrapExpression(object); + if (!unwrapped) return false; + if (unwrapped.kind() === "identifier") + return dbNames.has(unwrapped.text()) && !isShadowed(unwrapped, shadowedRanges); + if (unwrapped.kind() !== "member_expression") return false; + + const base = unwrapExpression(unwrapped.field("object")); + const property = memberProperty(unwrapped); + return ( + base?.kind() === "identifier" && + namespaceNames.has(base.text()) && + !isShadowed(base, shadowedRanges) && + property?.text() === "db" + ); +} + +function memberProperty(member: SgNode): SgNode | null { + const children = member.children(); + for (let index = children.length - 1; index >= 0; index -= 1) { + const child = children[index]!; + if (child.kind() === "property_identifier") return child; + } + return member.field("property"); +} + +function typeStringLiteral(node: SgNode | null): SgNode | null { + if (!node) return null; + const kind = node.kind(); + if (kind !== "string" && kind !== "template_string") return null; + const fragments = node.children().filter((child) => child.kind() === "string_fragment"); + return fragments.length === 1 && fragments[0]!.text() === "type" ? node : null; +} + +function replaceStringLiteralValue(node: SgNode, value: string): Edit { + const text = node.text(); + const quote = text.startsWith("'") ? "'" : text.startsWith("`") ? "`" : '"'; + return node.replace(`${quote}${value}${quote}`); +} + +function hasTypeBuilderUse(root: SgNode, name: string, afterIndex: number): boolean { + for (const member of root.findAll({ rule: { kind: "member_expression" } })) { + if (member.range().start.index <= afterIndex) continue; + if (memberProperty(member)?.text() !== "type") continue; + const object = unwrapExpression(member.field("object")); + if (object?.kind() === "identifier" && object.text() === name) return true; + } + + for (const subscript of root.findAll({ rule: { kind: "subscript_expression" } })) { + if (subscript.range().start.index <= afterIndex) continue; + if (!typeStringLiteral(subscript.field("index"))) continue; + const object = unwrapExpression(subscript.field("object")); + if (object?.kind() === "identifier" && object.text() === name) return true; + } + + return false; +} + +export default function transform(source: string, filePath: string): string | null { + if (!source.includes("type") || !source.includes(SDK_MODULE)) return null; + + let root: SgNode; + try { + root = parse(sourceLang(filePath, source), source).root(); + } catch { + return null; + } + + const imports = findImportStatements(root).filter( + (importStmt) => importSource(importStmt) === SDK_MODULE, + ); + if (imports.length === 0) return null; + + const dbNames = new Set(); + const namespaceNames = new Set(); + for (const importStmt of imports) { + for (const binding of importBindings(importStmt)) { + if (binding.importedName === "db") dbNames.add(binding.localName); + } + for (const name of namespaceImportNames(importStmt)) { + namespaceNames.add(name); + } + } + if (dbNames.size === 0 && namespaceNames.size === 0) return null; + + const shadowedRanges = buildShadowedRanges(root, new Set([...dbNames, ...namespaceNames])); + const edits: Edit[] = []; + for (const member of root.findAll({ rule: { kind: "member_expression" } })) { + const property = memberProperty(member); + if (property?.text() !== "type") continue; + if (!isSdkDbMember(member.field("object"), dbNames, namespaceNames, shadowedRanges)) continue; + edits.push(property.replace("table")); + } + for (const subscript of root.findAll({ rule: { kind: "subscript_expression" } })) { + const index = typeStringLiteral(subscript.field("index")); + if (!index) continue; + if (!isSdkDbMember(subscript.field("object"), dbNames, namespaceNames, shadowedRanges)) { + continue; + } + edits.push(replaceStringLiteralValue(index, "table")); + } + + return edits.length > 0 ? root.commitEdits(edits) : null; +} + +function lineForIndex(source: string, index: number): number { + return source.slice(0, index).split(/\r\n|\r|\n/).length; +} + +function excerptAtIndex(source: string, index: number): string { + const lineStart = Math.max(source.lastIndexOf("\n", index - 1) + 1, 0); + const lineEnd = source.indexOf("\n", index); + return source.slice(lineStart, lineEnd === -1 ? source.length : lineEnd).trim(); +} + +function objectPatternHasTypeProperty(pattern: SgNode): boolean { + return pattern + .findAll({ + rule: { + any: [ + { kind: "property_identifier", regex: "^type$" }, + { kind: "shorthand_property_identifier_pattern", regex: "^type$" }, + ], + }, + }) + .some((node) => node.text() === "type"); +} + +function namespaceDbAliasBindings(pattern: SgNode): SgNode[] { + const aliases: SgNode[] = []; + for (const child of pattern.children()) { + if (child.kind() === "shorthand_property_identifier_pattern" && child.text() === "db") { + aliases.push(child); + continue; + } + if (child.kind() !== "pair_pattern") continue; + const key = child.field("key"); + if (key?.text() !== "db") continue; + const value = child.field("value"); + if (value?.kind() === "identifier") aliases.push(value); + } + return aliases; +} + +function isSdkNamespaceMember( + node: SgNode | null, + namespaceNames: Set, + shadowedRanges: Map>, +): boolean { + const unwrapped = unwrapExpression(node); + return ( + unwrapped?.kind() === "identifier" && + namespaceNames.has(unwrapped.text()) && + !isShadowed(unwrapped, shadowedRanges) + ); +} + +export function reviewFindings( + source: string, + filePath: string, + relativePath: string, +): LlmReviewFinding[] { + if (!source.includes("type") || !source.includes(SDK_MODULE)) return []; + + let root: SgNode; + try { + root = parse(sourceLang(filePath, source), source).root(); + } catch { + return []; + } + + const imports = findImportStatements(root).filter( + (importStmt) => importSource(importStmt) === SDK_MODULE, + ); + if (imports.length === 0) return []; + + const dbNames = new Set(); + const namespaceNames = new Set(); + for (const importStmt of imports) { + for (const binding of importBindings(importStmt)) { + if (binding.importedName === "db") dbNames.add(binding.localName); + } + for (const name of namespaceImportNames(importStmt)) { + namespaceNames.add(name); + } + } + if (dbNames.size === 0 && namespaceNames.size === 0) return []; + + const shadowedRanges = buildShadowedRanges(root, new Set([...dbNames, ...namespaceNames])); + const findings: LlmReviewFinding[] = []; + + for (const decl of root.findAll({ rule: { kind: "variable_declarator" } })) { + const binding = firstDeclaratorChild(decl); + if (binding?.kind() !== "object_pattern" || !objectPatternHasTypeProperty(binding)) continue; + const value = declaratorValue(decl); + if (!isSdkDbMember(value, dbNames, namespaceNames, shadowedRanges)) continue; + + findings.push({ + file: relativePath, + line: lineForIndex(source, binding.range().start.index), + message: "Review destructured db.type builder usage and migrate it to db.table.", + excerpt: excerptAtIndex(source, binding.range().start.index), + }); + } + + for (const decl of root.findAll({ rule: { kind: "variable_declarator" } })) { + const binding = firstDeclaratorChild(decl); + if (binding?.kind() !== "object_pattern") continue; + const value = declaratorValue(decl); + if (!isSdkNamespaceMember(value, namespaceNames, shadowedRanges)) continue; + + for (const alias of namespaceDbAliasBindings(binding)) { + if (!hasTypeBuilderUse(root, alias.text(), decl.range().end.index)) continue; + findings.push({ + file: relativePath, + line: lineForIndex(source, binding.range().start.index), + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: excerptAtIndex(source, binding.range().start.index), + }); + } + } + + for (const decl of root.findAll({ rule: { kind: "variable_declarator" } })) { + const binding = firstDeclaratorChild(decl); + if (binding?.kind() !== "identifier") continue; + const value = declaratorValue(decl); + if (!isSdkDbMember(value, dbNames, namespaceNames, shadowedRanges)) continue; + if (!hasTypeBuilderUse(root, binding.text(), decl.range().end.index)) continue; + + findings.push({ + file: relativePath, + line: lineForIndex(source, binding.range().start.index), + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: excerptAtIndex(source, binding.range().start.index), + }); + } + + for (const assignment of root.findAll({ rule: { kind: "assignment_expression" } })) { + const target = assignmentTarget(assignment); + if (target?.kind() !== "identifier") continue; + const value = assignmentValue(assignment); + if (!isSdkDbMember(value, dbNames, namespaceNames, shadowedRanges)) continue; + if (!hasTypeBuilderUse(root, target.text(), assignment.range().end.index)) continue; + + findings.push({ + file: relativePath, + line: lineForIndex(source, assignment.range().start.index), + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: excerptAtIndex(source, assignment.range().start.index), + }); + } + + for (const param of root.findAll({ + rule: { any: [{ kind: "required_parameter" }, { kind: "optional_parameter" }] }, + })) { + const target = parameterDefaultTarget(param); + if (target?.kind() !== "identifier") continue; + const value = parameterDefaultValue(param); + if (!isSdkDbMember(value, dbNames, namespaceNames, shadowedRanges)) continue; + if (!hasTypeBuilderUse(root, target.text(), param.range().end.index)) continue; + + findings.push({ + file: relativePath, + line: lineForIndex(source, param.range().start.index), + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: excerptAtIndex(source, param.range().start.index), + }); + } + + return findings; +} diff --git a/packages/sdk-codemod/codemods/v2/db-type-to-table/tests/sdk-import/expected.ts b/packages/sdk-codemod/codemods/v2/db-type-to-table/tests/sdk-import/expected.ts new file mode 100644 index 000000000..da10b1b26 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/db-type-to-table/tests/sdk-import/expected.ts @@ -0,0 +1,102 @@ +import { db, defineConfig } from "@tailor-platform/sdk"; +import { db as schema } from "@tailor-platform/sdk"; +import * as sdk from "@tailor-platform/sdk"; + +export const user = db.table("User", { + name: db.string(), +}); + +export const project = schema.table("Project", { + title: schema.string(), +}); + +export const team = sdk.db.table("Team", { + label: sdk.db.string(), +}); + +export const optional = db.table?.("Optional", { + label: db.string(), +}); + +export const optionalTeam = sdk.db.table?.("OptionalTeam", { + label: sdk.db.string(), +}); + +export const computedUser = db["table"]("ComputedUser", { + label: db.string(), +}); + +export const computedProject = schema["table"]("ComputedProject", { + label: schema.string(), +}); + +export const computedTeam = sdk.db["table"]("ComputedTeam", { + label: sdk.db.string(), +}); + +export const computedTemplateUser = db[`table`]("ComputedTemplateUser", { + label: db.string(), +}); + +export const computedTemplateTeam = sdk.db[`table`]("ComputedTemplateTeam", { + label: sdk.db.string(), +}); + +export const parenthesizedUser = (db).table("ParenthesizedUser", { + label: db.string(), +}); + +export const assertedUser = (db as any).table("AssertedUser", { + label: db.string(), +}); + +const local = { + type: (name: string) => name, +}; + +local.type("NoChange"); + +function useLocalDb(db: { type: (name: string) => string }) { + return db.type("NoChange"); +} + +function useVarShadow(localDb: { type: (name: string) => string }) { + if (localDb) { + var db = localDb; + } + return db.type("NoChange"); +} + +const useBareArrowDb = (db) => db.type("NoChange"); + +const useBareArrowNamespace = (sdk) => sdk.db.type("NoChange"); + +{ + const schema = { + type: (name: string) => name, + }; + schema.type("NoChange"); +} + +{ + const { db } = { + db: { + type: (name: string) => name, + }, + }; + db.type("NoChange"); +} + +for (const db of [{ type: (name: string) => name }]) { + db.type("NoChange"); +} + +try { + throw { + type: (name: string) => name, + }; +} catch (db) { + db.type("NoChange"); +} + +defineConfig({}); diff --git a/packages/sdk-codemod/codemods/v2/db-type-to-table/tests/sdk-import/input.ts b/packages/sdk-codemod/codemods/v2/db-type-to-table/tests/sdk-import/input.ts new file mode 100644 index 000000000..ca134dd4a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/db-type-to-table/tests/sdk-import/input.ts @@ -0,0 +1,102 @@ +import { db, defineConfig } from "@tailor-platform/sdk"; +import { db as schema } from "@tailor-platform/sdk"; +import * as sdk from "@tailor-platform/sdk"; + +export const user = db.type("User", { + name: db.string(), +}); + +export const project = schema.type("Project", { + title: schema.string(), +}); + +export const team = sdk.db.type("Team", { + label: sdk.db.string(), +}); + +export const optional = db.type?.("Optional", { + label: db.string(), +}); + +export const optionalTeam = sdk.db.type?.("OptionalTeam", { + label: sdk.db.string(), +}); + +export const computedUser = db["type"]("ComputedUser", { + label: db.string(), +}); + +export const computedProject = schema["type"]("ComputedProject", { + label: schema.string(), +}); + +export const computedTeam = sdk.db["type"]("ComputedTeam", { + label: sdk.db.string(), +}); + +export const computedTemplateUser = db[`type`]("ComputedTemplateUser", { + label: db.string(), +}); + +export const computedTemplateTeam = sdk.db[`type`]("ComputedTemplateTeam", { + label: sdk.db.string(), +}); + +export const parenthesizedUser = (db).type("ParenthesizedUser", { + label: db.string(), +}); + +export const assertedUser = (db as any).type("AssertedUser", { + label: db.string(), +}); + +const local = { + type: (name: string) => name, +}; + +local.type("NoChange"); + +function useLocalDb(db: { type: (name: string) => string }) { + return db.type("NoChange"); +} + +function useVarShadow(localDb: { type: (name: string) => string }) { + if (localDb) { + var db = localDb; + } + return db.type("NoChange"); +} + +const useBareArrowDb = (db) => db.type("NoChange"); + +const useBareArrowNamespace = (sdk) => sdk.db.type("NoChange"); + +{ + const schema = { + type: (name: string) => name, + }; + schema.type("NoChange"); +} + +{ + const { db } = { + db: { + type: (name: string) => name, + }, + }; + db.type("NoChange"); +} + +for (const db of [{ type: (name: string) => name }]) { + db.type("NoChange"); +} + +try { + throw { + type: (name: string) => name, + }; +} catch (db) { + db.type("NoChange"); +} + +defineConfig({}); diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/codemod.yaml b/packages/sdk-codemod/codemods/v2/env-var-rename/codemod.yaml new file mode 100644 index 000000000..33ec20299 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/env-var-rename" +version: "1.0.0" +description: "Rewrite removed SDK environment variable names to their v2 `TAILOR_*` names" +engine: jssg +language: text +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/env-var-rename/scripts/transform.ts new file mode 100644 index 000000000..8ad057c0b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/scripts/transform.ts @@ -0,0 +1,100 @@ +import { parse, Lang } from "@ast-grep/napi"; +import * as path from "pathe"; +import type { SgNode } from "@ast-grep/napi"; + +const ENV_RENAMES: ReadonlyArray = [ + ["TAILOR_PLATFORM_SDK_CONFIG_PATH", "TAILOR_CONFIG_PATH"], + ["TAILOR_PLATFORM_SDK_DTS_PATH", "TAILOR_DTS_PATH"], + ["TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION", "TAILOR_CI_ALLOW_ID_INJECTION"], + ["TAILOR_PLATFORM_SDK_BUILD_ONLY", "TAILOR_DEPLOY_BUILD_ONLY"], + ["TAILOR_SDK_OUTPUT_DIR", "TAILOR_BUILD_OUTPUT_DIR"], + ["TAILOR_SDK_SKILLS_SOURCE", "TAILOR_SKILLS_SOURCE"], + ["TAILOR_SDK_VERSION", "TAILOR_TEMPLATE_SDK_VERSION"], + ["TAILOR_ENABLE_INLINE_SOURCEMAP", "TAILOR_INLINE_SOURCEMAP"], + ["TAILOR_PLATFORM_QUERY_NEWLINE_ON_ENTER", "TAILOR_QUERY_NEWLINE_ON_ENTER"], + ["TAILOR_TOKEN", "TAILOR_PLATFORM_TOKEN"], +]; + +const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]); +const ENV_BOUNDARY = "[A-Za-z0-9_]"; +const RENAME_PATTERNS = ENV_RENAMES.map(([from, to]) => ({ + pattern: new RegExp(`(? { + const edits: Array<[number, number, string]> = []; + const visit = (node: SgNode): void => { + if (node.kind() === "string_fragment") { + const range = node.range(); + const text = source.slice(range.start.index, range.end.index); + const replacement = replaceTextTokens(text); + if (replacement !== text) edits.push([range.start.index, range.end.index, replacement]); + return; + } + for (const child of node.children()) { + visit(child); + } + }; + visit(root); + return edits; +} + +function replaceSourceStringFragments(source: string, filePath: string): string { + let root: SgNode; + try { + root = parse(sourceLang(filePath), source).root(); + } catch { + return source; + } + + let updated = source; + const edits = collectStringFragmentEdits(root, source).toSorted(([a], [b]) => b - a); + for (const [start, end, replacement] of edits) { + updated = `${updated.slice(0, start)}${replacement}${updated.slice(end)}`; + } + return updated; +} + +function replaceSourceTokens(source: string, filePath: string): string { + let updated = source; + for (const [from, to] of ENV_RENAMES) { + const escaped = escapeRegExp(from); + updated = updated + .replace( + new RegExp(`\\bprocess\\.env\\.${escaped}(?![A-Za-z0-9_$])`, "g"), + `process.env.${to}`, + ) + .replace( + new RegExp(`\\bprocess\\.env\\[(["'\`])${escaped}\\1\\]`, "g"), + `process.env[$1${to}$1]`, + ) + .replace(new RegExp(`([,{]\\s*)${escaped}(?=\\s*:)`, "g"), `$1${to}`); + } + return replaceSourceStringFragments(updated, filePath); +} + +export default function transform(source: string, filePath: string): string | null { + const ext = path.extname(filePath).toLowerCase(); + const updated = SOURCE_EXTENSIONS.has(ext) + ? replaceSourceTokens(source, filePath) + : replaceTextTokens(source); + return updated === source ? null : updated; +} diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/expected.env b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/expected.env new file mode 100644 index 000000000..40665a69a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/expected.env @@ -0,0 +1,13 @@ +TAILOR_CONFIG_PATH=tailor.config.ts +TAILOR_DTS_PATH=types/tailor.d.ts +TAILOR_CI_ALLOW_ID_INJECTION=true +TAILOR_DEPLOY_BUILD_ONLY=true +TAILOR_BUILD_OUTPUT_DIR=.tailor-sdk +TAILOR_SKILLS_SOURCE=./skills +TAILOR_TEMPLATE_SDK_VERSION=2.0.0-next.2 +PLATFORM_URL=https://api.staging.tailor.tech +PLATFORM_OAUTH2_CLIENT_ID=client-id +TAILOR_INLINE_SOURCEMAP=false +TAILOR_QUERY_NEWLINE_ON_ENTER=false +LOG_LEVEL=DEBUG +TAILOR_PLATFORM_TOKEN=token diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/input.env b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/input.env new file mode 100644 index 000000000..3dd923480 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/input.env @@ -0,0 +1,13 @@ +TAILOR_PLATFORM_SDK_CONFIG_PATH=tailor.config.ts +TAILOR_PLATFORM_SDK_DTS_PATH=types/tailor.d.ts +TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION=true +TAILOR_PLATFORM_SDK_BUILD_ONLY=true +TAILOR_SDK_OUTPUT_DIR=.tailor-sdk +TAILOR_SDK_SKILLS_SOURCE=./skills +TAILOR_SDK_VERSION=2.0.0-next.2 +PLATFORM_URL=https://api.staging.tailor.tech +PLATFORM_OAUTH2_CLIENT_ID=client-id +TAILOR_ENABLE_INLINE_SOURCEMAP=false +TAILOR_PLATFORM_QUERY_NEWLINE_ON_ENTER=false +LOG_LEVEL=DEBUG +TAILOR_TOKEN=token diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/expected.md b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/expected.md new file mode 100644 index 000000000..8a66a45f5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/expected.md @@ -0,0 +1,5 @@ +Configure the SDK with `TAILOR_CONFIG_PATH`. + +```sh +TAILOR_BUILD_OUTPUT_DIR=.tailor-sdk LOG_LEVEL=DEBUG tailor-sdk generate +``` diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/input.md b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/input.md new file mode 100644 index 000000000..6e7b9e553 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/input.md @@ -0,0 +1,5 @@ +Configure the SDK with `TAILOR_PLATFORM_SDK_CONFIG_PATH`. + +```sh +TAILOR_SDK_OUTPUT_DIR=.tailor-sdk LOG_LEVEL=DEBUG tailor-sdk generate +``` diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/expected.json b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/expected.json new file mode 100644 index 000000000..f921b7ad7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/expected.json @@ -0,0 +1,10 @@ +{ + "scripts": { + "deploy": "TAILOR_DEPLOY_BUILD_ONLY=true tailor-sdk deploy", + "generate": "TAILOR_INLINE_SOURCEMAP=false TAILOR_BUILD_OUTPUT_DIR=.tailor-sdk tailor-sdk generate" + }, + "config": { + "TAILOR_TEMPLATE_SDK_VERSION": "workspace:*", + "PLATFORM_URL": "https://api.tailor.test" + } +} diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/input.json b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/input.json new file mode 100644 index 000000000..36379a1dd --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/input.json @@ -0,0 +1,10 @@ +{ + "scripts": { + "deploy": "TAILOR_PLATFORM_SDK_BUILD_ONLY=true tailor-sdk deploy", + "generate": "TAILOR_ENABLE_INLINE_SOURCEMAP=false TAILOR_SDK_OUTPUT_DIR=.tailor-sdk tailor-sdk generate" + }, + "config": { + "TAILOR_SDK_VERSION": "workspace:*", + "PLATFORM_URL": "https://api.tailor.test" + } +} diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/expected.ts b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/expected.ts new file mode 100644 index 000000000..10e19de4a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/expected.ts @@ -0,0 +1,11 @@ +const configPath = process.env.TAILOR_CONFIG_PATH; +const dtsPath = process.env["TAILOR_DTS_PATH"]; +const baseUrl = process.env.PLATFORM_URL ?? "https://api.tailor.tech"; +const logLevel = process.env.LOG_LEVEL ?? "DEBUG"; +const token = process.env.TAILOR_PLATFORM_TOKEN; +const env = { LOG_LEVEL: "DEBUG", TAILOR_PLATFORM_TOKEN: token }; +const command = "TAILOR_DEPLOY_BUILD_ONLY=true tailor-sdk deploy"; +const templateCommand = `TAILOR_DEPLOY_BUILD_ONLY=${buildOnly}`; +const localInterpolation = `${TAILOR_TOKEN}`; +const LOG_LEVEL = "local"; +const unchanged = process.env.MY_LOG_LEVEL ?? process.env.TAILOR_TOKEN_BACKUP; diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/input.ts b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/input.ts new file mode 100644 index 000000000..c518f8301 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/input.ts @@ -0,0 +1,11 @@ +const configPath = process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH; +const dtsPath = process.env["TAILOR_PLATFORM_SDK_DTS_PATH"]; +const baseUrl = process.env.PLATFORM_URL ?? "https://api.tailor.tech"; +const logLevel = process.env.LOG_LEVEL ?? "DEBUG"; +const token = process.env.TAILOR_TOKEN; +const env = { LOG_LEVEL: "DEBUG", TAILOR_TOKEN: token }; +const command = "TAILOR_PLATFORM_SDK_BUILD_ONLY=true tailor-sdk deploy"; +const templateCommand = `TAILOR_PLATFORM_SDK_BUILD_ONLY=${buildOnly}`; +const localInterpolation = `${TAILOR_TOKEN}`; +const LOG_LEVEL = "local"; +const unchanged = process.env.MY_LOG_LEVEL ?? process.env.TAILOR_TOKEN_BACKUP; diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/expected.yml b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/expected.yml new file mode 100644 index 000000000..e8611b398 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/expected.yml @@ -0,0 +1,8 @@ +name: deploy +jobs: + deploy: + env: + TAILOR_DEPLOY_BUILD_ONLY: "true" + TAILOR_CI_ALLOW_ID_INJECTION: "true" + steps: + - run: PLATFORM_OAUTH2_CLIENT_ID=client PLATFORM_URL=https://api.tailor.test tailor-sdk login diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/input.yml b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/input.yml new file mode 100644 index 000000000..f18c1f060 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/input.yml @@ -0,0 +1,8 @@ +name: deploy +jobs: + deploy: + env: + TAILOR_PLATFORM_SDK_BUILD_ONLY: "true" + TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION: "true" + steps: + - run: PLATFORM_OAUTH2_CLIENT_ID=client PLATFORM_URL=https://api.tailor.test tailor-sdk login diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/no-match/input.sh b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/no-match/input.sh new file mode 100644 index 000000000..84839d136 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/no-match/input.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +MY_LOG_LEVEL=debug +myLOG_LEVEL=debug +fooPLATFORM_URL=https://api.example.test +TAILOR_TOKEN_BACKUP=token +echo "$MY_LOG_LEVEL:$myLOG_LEVEL:$fooPLATFORM_URL:$TAILOR_TOKEN_BACKUP" diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/expected.sh b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/expected.sh new file mode 100644 index 000000000..eff853131 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/expected.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +echo "$LOG_LEVEL" +echo "${PLATFORM_URL}" +echo "${TAILOR_PLATFORM_TOKEN:-missing}" diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/input.sh b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/input.sh new file mode 100644 index 000000000..8cf1128c2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/input.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +echo "$LOG_LEVEL" +echo "${PLATFORM_URL}" +echo "${TAILOR_TOKEN:-missing}" diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/codemod.yaml b/packages/sdk-codemod/codemods/v2/execute-script-arg/codemod.yaml new file mode 100644 index 000000000..68eb43f1c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/execute-script-arg" +version: "1.0.0" +description: "Unwrap JSON.stringify(...) passed as the executeScript `arg` option; v2 takes a JSON-serializable value directly" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/scripts/transform.ts new file mode 100644 index 000000000..84d9bc033 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/scripts/transform.ts @@ -0,0 +1,72 @@ +import { parse, Lang } from "@ast-grep/napi"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const NEEDLE = "executeScript"; + +function quickFilter(source: string): boolean { + return source.includes(NEEDLE) && source.includes("JSON.stringify"); +} + +function pairKeyText(pair: SgNode): string | null { + const key = pair.children()[0]; + if (!key) return null; + return key.text().replace(/^['"]|['"]$/g, ""); +} + +/** + * True when `stringifyCall` is the value of a top-level `arg:` property in the + * object literal passed directly to `executeScript(...)`. The chain checked is + * `JSON.stringify(...)` → pair (`arg:`) → object → arguments → `executeScript` + * call, so a nested `arg:` (e.g. `executeScript({ opts: { arg: ... } })`) or an + * unrelated `JSON.stringify` is left untouched. + */ +function isExecuteScriptArg(stringifyCall: SgNode): boolean { + const pair = stringifyCall.parent(); + if (!pair || pair.kind() !== "pair") return false; + if (pairKeyText(pair) !== "arg") return false; + + const obj = pair.parent(); + if (!obj || obj.kind() !== "object") return false; + + const args = obj.parent(); + if (!args || args.kind() !== "arguments") return false; + + const call = args.parent(); + if (!call || call.kind() !== "call_expression") return false; + + const callee = call.children()[0]; + return !!callee && callee.text() === NEEDLE; +} + +/** + * Rewrite `executeScript({ ..., arg: JSON.stringify(X), ... })` to + * `executeScript({ ..., arg: X, ... })`. + * + * In v2 the `executeScript` `arg` option takes a JSON-serializable value and + * serializes it internally, so a pre-stringified argument double-encodes. Only + * the literal `arg: JSON.stringify()` form is rewritten; indirect + * forms (a stringified value held in a variable, `JSON.stringify(x, null, 2)`, + * etc.) are left for manual migration. + * @param source - File contents + * @param _filePath - Absolute path to the file (kept for the runner signature) + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, _filePath: string): string | null { + if (!quickFilter(source)) return null; + + const lang = source.includes("") ? Lang.Tsx : Lang.TypeScript; + const root = parse(lang, source).root(); + + const edits: Edit[] = []; + for (const match of root.findAll({ rule: { pattern: "JSON.stringify($X)" } })) { + if (!isExecuteScriptArg(match)) continue; + const inner = match.getMatch("X"); + if (!inner) continue; + edits.push(match.replace(inner.text())); + } + + if (edits.length === 0) return null; + + const result = root.commitEdits(edits); + return result === source ? null : result; +} diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/expected.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/expected.ts new file mode 100644 index 000000000..800d5103f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/expected.ts @@ -0,0 +1,8 @@ +const result = await executeScript({ + client, + workspaceId, + name: "seed.ts", + code: bundledCode, + arg: { users: rows }, + invoker, +}); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/input.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/input.ts new file mode 100644 index 000000000..7d02ccb76 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/input.ts @@ -0,0 +1,8 @@ +const result = await executeScript({ + client, + workspaceId, + name: "seed.ts", + code: bundledCode, + arg: JSON.stringify({ users: rows }), + invoker, +}); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/expected.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/expected.ts new file mode 100644 index 000000000..03a361817 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/expected.ts @@ -0,0 +1 @@ +await executeScript({ client, workspaceId, code, arg: payload, invoker }); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/input.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/input.ts new file mode 100644 index 000000000..665cfdccf --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/input.ts @@ -0,0 +1 @@ +await executeScript({ client, workspaceId, code, arg: JSON.stringify(payload), invoker }); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/indirect-variable/input.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/indirect-variable/input.ts new file mode 100644 index 000000000..40efb78e5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/indirect-variable/input.ts @@ -0,0 +1,2 @@ +const serialized = JSON.stringify({ users: rows }); +await executeScript({ client, workspaceId, code, arg: serialized, invoker }); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/multi-arg-stringify/input.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/multi-arg-stringify/input.ts new file mode 100644 index 000000000..b049d1c7d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/multi-arg-stringify/input.ts @@ -0,0 +1 @@ +await executeScript({ client, workspaceId, code, arg: JSON.stringify(payload, null, 2), invoker }); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/non-arg-key/input.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/non-arg-key/input.ts new file mode 100644 index 000000000..e85e7287f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/non-arg-key/input.ts @@ -0,0 +1 @@ +await executeScript({ client, workspaceId, code: JSON.stringify(meta), invoker }); diff --git a/packages/sdk-codemod/codemods/v2/forward-relation-name/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/forward-relation-name/scripts/transform.ts new file mode 100644 index 000000000..e634cddad --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/forward-relation-name/scripts/transform.ts @@ -0,0 +1,154 @@ +import { parse, Lang } from "@ast-grep/napi"; +import { stringValue } from "../../../../src/ast-grep-helpers"; +import type { LlmReviewFinding } from "../../../../src/types"; +import type { SgNode } from "@ast-grep/napi"; + +function sourceLang(filePath: string, source: string): Lang { + const lowerPath = filePath.toLowerCase(); + if (/\.(?:ts|mts|cts)$/u.test(lowerPath)) return Lang.TypeScript; + if (/\.(?:tsx|jsx|js)$/u.test(lowerPath)) return Lang.Tsx; + return source.includes("): boolean { + const callee = call.children()[0]; + if (!callee) return false; + if (callee.kind() === "identifier") return aliases.has(callee.text()); + if (callee.kind() === "subscript_expression") { + const property = literalStringValue(callee.field("index")); + return property === null || property === "relation"; + } + if (callee.kind() !== "member_expression") return false; + + const property = callee + .children() + .findLast((child) => child.kind() === "property_identifier" || child.kind() === "identifier"); + return property?.text() === "relation"; +} + +function relationBindingName(pattern: SgNode): string | null { + if (pattern.kind() !== "object_pattern") return null; + + for (const child of pattern.children()) { + if (child.kind() === "shorthand_property_identifier_pattern" && child.text() === "relation") { + return child.text(); + } + if (child.kind() === "pair_pattern" && stringValue(child.field("key")) === "relation") { + const value = child.field("value"); + return value?.kind() === "identifier" ? value.text() : null; + } + if (child.kind() === "object_assignment_pattern") { + const binding = child + .children() + .find((node) => node.kind() === "shorthand_property_identifier_pattern"); + if (binding?.text() === "relation") return binding.text(); + } + } + + return null; +} + +function relationAliases(root: SgNode): Set { + const aliases = new Set(); + for (const pattern of root.findAll({ rule: { kind: "object_pattern" } })) { + const name = relationBindingName(pattern); + if (name) aliases.add(name); + } + return aliases; +} + +function callArgument(call: SgNode): SgNode | null { + const args = call.children().find((child) => child.kind() === "arguments"); + if (!args) return null; + + const values = args.children().filter((child) => { + const kind = child.kind(); + return kind !== "(" && kind !== ")" && kind !== "," && kind !== "comment"; + }); + return values.length === 1 ? values[0]! : null; +} + +function pairKey(pair: SgNode): string | null { + const key = pair.children()[0]; + return stringValue(key ?? null); +} + +function pairValue(pair: SgNode): SgNode | null { + const children = pair.children(); + const colonIndex = children.findIndex((child) => child.kind() === ":"); + if (colonIndex === -1) return null; + return children.slice(colonIndex + 1).find((child) => child.kind() !== "comment") ?? null; +} + +function objectPair(object: SgNode, key: string): SgNode | null { + return ( + object.children().find((child) => child.kind() === "pair" && pairKey(child) === key) ?? null + ); +} + +function literalStringValue(node: SgNode | null): string | null { + if (node?.kind() !== "string") return null; + return stringValue(node); +} + +function hasDynamicProperties(object: SgNode): boolean { + return object.children().some((child) => { + const kind = child.kind(); + if (kind === "{" || kind === "}" || kind === "," || kind === "comment") return false; + if (kind !== "pair") return true; + + const keyKind = child.children()[0]?.kind(); + return keyKind !== "property_identifier" && keyKind !== "string"; + }); +} + +function needsReview(call: SgNode): boolean { + const config = callArgument(call); + if (config?.kind() !== "object") return config != null; + if (hasDynamicProperties(config)) return true; + + const relationType = objectPair(config, "type"); + const toward = objectPair(config, "toward"); + if (!relationType || !toward) return false; + if (literalStringValue(pairValue(relationType)) === "keyOnly") return false; + + const towardConfig = pairValue(toward); + if (towardConfig?.kind() !== "object") return towardConfig != null; + if (hasDynamicProperties(towardConfig)) return true; + + const targetType = objectPair(towardConfig, "type"); + if (targetType && literalStringValue(pairValue(targetType)) === "self") return false; + + const as = objectPair(towardConfig, "as"); + if (as) { + const explicitName = literalStringValue(pairValue(as)); + return explicitName === null || explicitName.length === 0; + } + + if (!targetType) return false; + return true; +} + +export default function transform(_source: string, _filePath: string): null { + return null; +} + +export function reviewFindings( + source: string, + filePath: string, + relativePath: string, +): LlmReviewFinding[] { + if (!source.includes("relation")) return []; + + const root = parse(sourceLang(filePath, source), source).root(); + const aliases = relationAliases(root); + return root + .findAll({ rule: { kind: "call_expression" } }) + .filter((call) => isRelationCall(call, aliases) && needsReview(call)) + .map((call) => ({ + file: relativePath, + line: call.range().start.line + 1, + message: "Review the v2 forward GraphQL field name or add an explicit toward.as.", + excerpt: call.text().split("\n", 1)[0]!.trim(), + })); +} diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/codemod.yaml b/packages/sdk-codemod/codemods/v2/principal-unify/codemod.yaml index 1dd94b1ac..b6d95370e 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/codemod.yaml +++ b/packages/sdk-codemod/codemods/v2/principal-unify/codemod.yaml @@ -1,6 +1,6 @@ name: "@tailor-platform/principal-unify" version: "1.0.0" -description: "Unify TailorUser/TailorActor/TailorInvoker into TailorPrincipal and rename resolver body `user` → `caller`" +description: "Unify TailorUser/TailorActor/TailorActorType/TailorInvoker into TailorPrincipal and rename resolver body `user` → `caller`" engine: jssg language: typescript since: "1.0.0" diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/principal-unify/scripts/transform.ts index 320075503..8392d4a86 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/scripts/transform.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/scripts/transform.ts @@ -1,15 +1,36 @@ import { parse, Lang } from "@ast-grep/napi"; +import type { LlmReviewFinding } from "../../../../src/types"; import type { Edit, SgNode } from "@ast-grep/napi"; const TYPE_RENAME_MAP: Record = { TailorUser: "TailorPrincipal", TailorActor: "TailorPrincipal", + TailorActorType: "TailorPrincipal", TailorInvoker: "TailorPrincipal", }; const UNAUTHENTICATED = "unauthenticatedTailorUser"; -const QUICK_FILTER_NEEDLES = [...Object.keys(TYPE_RENAME_MAP), UNAUTHENTICATED, "createResolver"]; +const QUICK_FILTER_NEEDLES = [ + ...Object.keys(TYPE_RENAME_MAP), + UNAUTHENTICATED, + "userId", + "userType", + "createResolver", + ".hooks", + ".validate", + ".parse", +]; + +const ACTOR_PROPERTY_RENAME_MAP: Record = { + userId: "id", + userType: "type", +}; + +const ACTOR_TYPE_LITERAL_RENAME_MAP: Record = { + USER_TYPE_USER: "user", + USER_TYPE_MACHINE_USER: "machine_user", +}; function quickFilter(source: string): boolean { if (!source.includes("@tailor-platform/sdk")) return false; @@ -25,14 +46,496 @@ function isInsideImportStatement(node: SgNode): boolean { return false; } -function isMemberExpressionObject(node: SgNode): boolean { +function memberObjectParent(node: SgNode): SgNode | null { const parent = node.parent(); - if (!parent || parent.kind() !== "member_expression") return false; + if ( + !parent || + (parent.kind() !== "member_expression" && parent.kind() !== "subscript_expression") + ) { + return null; + } const obj = parent.field("object"); - if (!obj) return false; + if (!obj) return null; const r = node.range(); const or = obj.range(); - return r.start.index === or.start.index && r.end.index === or.end.index; + if (r.start.index !== or.start.index || r.end.index !== or.end.index) return null; + return parent; +} + +function isMemberExpressionObject(node: SgNode): boolean { + return memberObjectParent(node) !== null; +} + +function optionalPrincipalReadKind(node: SgNode): "property" | "computed" | null { + const parent = memberObjectParent(node); + if (!parent) return null; + if (parent.text().startsWith(`${node.text()}?.`)) return null; + if (isAssignmentTargetReference(node)) return null; + if (parent.kind() === "subscript_expression") return "computed"; + return parent.field("property")?.kind() === "property_identifier" ? "property" : null; +} + +function isOptionalizableMemberObject(node: SgNode): boolean { + return optionalPrincipalReadKind(node) !== null; +} + +function principalIdentifierReplacement(node: SgNode, name: string): string { + const readKind = optionalPrincipalReadKind(node); + if (readKind === "computed") return `${name}?.`; + if (readKind === "property") return `${name}?`; + return name; +} + +function principalPropertyReplacement(node: SgNode, name: string): string { + const parent = node.parent(); + return parent ? principalIdentifierReplacement(parent, name) : name; +} + +function isObjectDestructureInitializer(node: SgNode): boolean { + const parent = node.parent(); + if (!parent || parent.kind() !== "variable_declarator") return false; + const value = parent.field("value"); + if (!value) return false; + const valueRange = value.range(); + const nodeRange = node.range(); + if ( + valueRange.start.index !== nodeRange.start.index || + valueRange.end.index !== nodeRange.end.index + ) { + return false; + } + return parent.field("name")?.kind() === "object_pattern"; +} + +function principalReadReplacement(node: SgNode, name: string): string { + return isObjectDestructureInitializer(node) + ? `${name} ?? {}` + : principalIdentifierReplacement(node, name); +} + +function nodeRangeContains(outer: SgNode, inner: SgNode): boolean { + const outerRange = outer.range(); + const innerRange = inner.range(); + return ( + innerRange.start.index >= outerRange.start.index && innerRange.end.index <= outerRange.end.index + ); +} + +function isAssignmentTargetReference(node: SgNode): boolean { + let current = node; + let parent = current.parent(); + while ( + parent && + (parent.kind() === "member_expression" || parent.kind() === "subscript_expression") + ) { + const object = parent.field("object"); + if (!object || !nodeRangeContains(object, current)) break; + current = parent; + parent = current.parent(); + } + + if (!parent) return false; + if (parent.kind() === "update_expression") return true; + if ( + parent.kind() !== "assignment_expression" && + parent.kind() !== "augmented_assignment_expression" + ) { + return false; + } + const left = parent.field("left"); + return !!left && nodeRangeContains(left, current); +} + +function parseArgumentCall(node: SgNode): SgNode | null { + if (node.kind() !== "shorthand_property_identifier") return null; + const object = node.parent(); + if (!object || object.kind() !== "object") return null; + const args = object.parent(); + if (!args || args.kind() !== "arguments") return null; + const call = args.parent(); + return call && call.kind() === "call_expression" && findMemberCallName(call) === "parse" + ? call + : null; +} + +interface SdkFieldParseContext { + sdkFieldRootNames: Set; + sdkFieldLocalBindings: SdkFieldLocalBinding[]; + root: SgNode; +} + +function isSdkFieldParseArgumentShorthand( + node: SgNode, + parseContext: SdkFieldParseContext, +): boolean { + const call = parseArgumentCall(node); + return call + ? isSdkFieldMemberCall( + call, + parseContext.sdkFieldRootNames, + parseContext.sdkFieldLocalBindings, + parseContext.root, + ) + : false; +} + +function addActorPropertyReplacement( + property: SgNode, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + const newName = ACTOR_PROPERTY_RENAME_MAP[property.text()]; + if (!newName) return; + const start = property.range().start.index; + if (transformedActorPropertyStarts.has(start)) return; + transformedActorPropertyStarts.add(start); + edits.push(property.replace(newName)); +} + +function actorTypeLiteralReplacement(literal: SgNode): string | null { + const match = literal + .text() + .match(/^(['"])(USER_TYPE_USER|USER_TYPE_MACHINE_USER|USER_TYPE_UNSPECIFIED)\1$/); + if (!match) return null; + const [, quote, value] = match; + if (value === "USER_TYPE_UNSPECIFIED") return "undefined"; + return `${quote}${ACTOR_TYPE_LITERAL_RENAME_MAP[value]!}${quote}`; +} + +function addActorTypeLiteralReplacement( + literal: SgNode, + edits: Edit[], + transformedLiteralStarts: Set, +): void { + if (literal.kind() !== "string") return; + const replacement = actorTypeLiteralReplacement(literal); + if (!replacement) return; + const start = literal.range().start.index; + if (transformedLiteralStarts.has(start)) return; + transformedLiteralStarts.add(start); + edits.push(literal.replace(replacement)); +} + +function transformActorTypeLiteralsInNode( + node: SgNode, + edits: Edit[], + transformedLiteralStarts: Set, +): void { + if (node.kind() === "string") { + addActorTypeLiteralReplacement(node, edits, transformedLiteralStarts); + } + const literals = node.findAll({ rule: { kind: "string" } }); + for (const literal of literals) { + addActorTypeLiteralReplacement(literal, edits, transformedLiteralStarts); + } +} + +function isTransformedActorTypeMember( + node: SgNode, + transformedActorPropertyStarts: Set, +): boolean { + if (node.kind() !== "member_expression") return false; + const property = node.field("property"); + return ( + property?.text() === "userType" && + transformedActorPropertyStarts.has(property.range().start.index) + ); +} + +function nodeContainsTransformedActorTypeMember( + node: SgNode, + transformedActorPropertyStarts: Set, +): boolean { + if (isTransformedActorTypeMember(node, transformedActorPropertyStarts)) return true; + const members = node.findAll({ rule: { kind: "member_expression" } }); + return members.some((member) => + isTransformedActorTypeMember(member, transformedActorPropertyStarts), + ); +} + +function switchDiscriminant(node: SgNode): SgNode | null { + return node.children().find((child) => child.kind() === "parenthesized_expression") ?? null; +} + +function switchBody(node: SgNode): SgNode | null { + return node.children().find((child) => child.kind() === "switch_body") ?? null; +} + +function transformActorTypeComparisonLiterals( + root: SgNode, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + if (transformedActorPropertyStarts.size === 0) return; + const transformedLiteralStarts = new Set(); + const binaries = root.findAll({ rule: { kind: "binary_expression" } }); + for (const binary of binaries) { + if ( + !binary + .children() + .some((child) => isTransformedActorTypeMember(child, transformedActorPropertyStarts)) + ) { + continue; + } + for (const child of binary.children()) { + addActorTypeLiteralReplacement(child, edits, transformedLiteralStarts); + } + } + + const switches = root.findAll({ rule: { kind: "switch_statement" } }); + for (const switchNode of switches) { + const discriminant = switchDiscriminant(switchNode); + if ( + !discriminant || + !nodeContainsTransformedActorTypeMember(discriminant, transformedActorPropertyStarts) + ) { + continue; + } + const body = switchBody(switchNode); + if (!body) continue; + const cases = body.findAll({ rule: { kind: "switch_case" } }); + for (const caseNode of cases) { + for (const child of caseNode.children()) { + addActorTypeLiteralReplacement(child, edits, transformedLiteralStarts); + } + } + } +} + +function transformTailorActorTypeInitializerLiterals( + root: SgNode, + actorTypeLocalNames: Set, + sdkNamespaceNames: Set, + edits: Edit[], +): void { + if (actorTypeLocalNames.size === 0 && sdkNamespaceNames.size === 0) return; + const transformedLiteralStarts = new Set(); + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + if ( + !isSdkTypeReference(decl, actorTypeLocalNames, "TailorActorType", sdkNamespaceNames, root) + ) { + continue; + } + const value = decl.field("value"); + if (!value) continue; + transformActorTypeLiteralsInNode(value, edits, transformedLiteralStarts); + } +} + +function transformActorTypeBindingComparisons( + root: SgNode, + binding: PrincipalLocalBinding, + edits: Edit[], +): void { + const refs = root.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(binding.name)}$` }, + }); + const transformedLiteralStarts = new Set(); + for (const ref of refs) { + const binary = ref.parent(); + if (!binary || binary.kind() !== "binary_expression") continue; + if ( + isShadowedLocalReference(root, binding.name, ref.range().start.index, binding.bindingStart) + ) { + continue; + } + for (const child of binary.children()) { + addActorTypeLiteralReplacement(child, edits, transformedLiteralStarts); + } + } + + const switches = root.findAll({ rule: { kind: "switch_statement" } }); + for (const switchNode of switches) { + const discriminant = switchDiscriminant(switchNode); + if (!discriminant) continue; + const refs = discriminant.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(binding.name)}$` }, + }); + const matchesBinding = refs.some( + (ref) => + !isShadowedLocalReference( + root, + binding.name, + ref.range().start.index, + binding.bindingStart, + ), + ); + if (!matchesBinding) continue; + const body = switchBody(switchNode); + if (!body) continue; + const cases = body.findAll({ rule: { kind: "switch_case" } }); + for (const caseNode of cases) { + for (const child of caseNode.children()) { + addActorTypeLiteralReplacement(child, edits, transformedLiteralStarts); + } + } + } +} + +function transformTailorActorTypeBindingComparisons( + root: SgNode, + actorTypeLocalNames: Set, + sdkNamespaceNames: Set, + edits: Edit[], +): void { + if (actorTypeLocalNames.size === 0 && sdkNamespaceNames.size === 0) return; + + for (const kind of NESTED_FN_KINDS) { + const fns = root.findAll({ rule: { kind } }); + for (const fn of fns) { + const param = getFirstFunctionParam(fn); + if ( + !param || + !isSdkTypeReference(param, actorTypeLocalNames, "TailorActorType", sdkNamespaceNames, root) + ) { + continue; + } + const pattern = getFunctionParamPattern(param); + const body = fn.field("body"); + if (!pattern || pattern.kind() !== "identifier" || !body) continue; + transformActorTypeBindingComparisons( + body, + { name: pattern.text(), bindingStart: pattern.range().start.index }, + edits, + ); + } + } + + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + if ( + !isSdkTypeReference(decl, actorTypeLocalNames, "TailorActorType", sdkNamespaceNames, root) + ) { + continue; + } + const name = decl.field("name"); + if (!name || name.kind() !== "identifier") continue; + transformActorTypeBindingComparisons( + root, + { name: name.text(), bindingStart: name.range().start.index }, + edits, + ); + } +} + +function transformActorBindingMemberAccesses( + root: SgNode, + binding: PrincipalLocalBinding, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + const refs = root.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(binding.name)}$` }, + }); + for (const ref of refs) { + const parent = ref.parent(); + if (!parent || parent.kind() !== "member_expression") continue; + const object = parent.field("object"); + if (!object || object.range().start.index !== ref.range().start.index) continue; + const property = parent.field("property"); + if (!property || property.kind() !== "property_identifier") continue; + if (!ACTOR_PROPERTY_RENAME_MAP[property.text()]) continue; + const pos = ref.range().start.index; + if (isShadowedLocalReference(root, binding.name, pos, binding.bindingStart)) continue; + addActorPropertyReplacement(property, edits, transformedActorPropertyStarts); + } +} + +function isSdkTypeReference( + node: SgNode, + localNames: Set, + sdkTypeName: string, + sdkNamespaceNames: Set, + root: SgNode, +): boolean { + const typeAnnotation = node.field("type"); + if (!typeAnnotation) return false; + const typeIds = typeAnnotation.findAll({ rule: { kind: "type_identifier" } }); + return typeIds.some( + (id) => + localNames.has(id.text()) || + (id.text() === sdkTypeName && + isSdkNamespaceQualifiedTypeIdentifier(id, sdkNamespaceNames, root)), + ); +} + +function transformTailorActorTypedMemberAccesses( + root: SgNode, + actorTypeLocalNames: Set, + sdkNamespaceNames: Set, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + if (actorTypeLocalNames.size === 0 && sdkNamespaceNames.size === 0) return; + + for (const kind of NESTED_FN_KINDS) { + const fns = root.findAll({ rule: { kind } }); + for (const fn of fns) { + const param = getFirstFunctionParam(fn); + if ( + !param || + !isSdkTypeReference(param, actorTypeLocalNames, "TailorActor", sdkNamespaceNames, root) + ) { + continue; + } + const pattern = getFunctionParamPattern(param); + const body = fn.field("body"); + if (!pattern || pattern.kind() !== "identifier" || !body) continue; + transformActorBindingMemberAccesses( + body, + { name: pattern.text(), bindingStart: pattern.range().start.index }, + edits, + transformedActorPropertyStarts, + ); + } + } + + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + if (!isSdkTypeReference(decl, actorTypeLocalNames, "TailorActor", sdkNamespaceNames, root)) { + continue; + } + const name = decl.field("name"); + if (!name || name.kind() !== "identifier") continue; + transformActorBindingMemberAccesses( + root, + { name: name.text(), bindingStart: name.range().start.index }, + edits, + transformedActorPropertyStarts, + ); + } +} + +function transformExecutorCtxActorAccesses( + body: SgNode, + ctxName: string, + ctxShadowRanges: Array<[number, number]>, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + const properties = body.findAll({ + rule: { kind: "property_identifier", regex: "^(userId|userType)$" }, + }); + for (const property of properties) { + const parent = property.parent(); + if (!parent || parent.kind() !== "member_expression") continue; + const object = parent.field("object"); + if (!object || object.kind() !== "member_expression") continue; + const actorProperty = object.field("property"); + if (actorProperty?.text() !== "actor") continue; + const ctxObject = object.field("object"); + if (!ctxObject || ctxObject.kind() !== "identifier" || ctxObject.text() !== ctxName) continue; + const pos = ctxObject.range().start.index; + if (isInsideAnyRange(pos, ctxShadowRanges)) continue; + addActorPropertyReplacement(property, edits, transformedActorPropertyStarts); + } +} + +function renamedTypeIdentifierText(name: string): string | null { + if (name === "TailorInvoker") return "(TailorPrincipal | null)"; + if (name === "TailorActorType") return '(TailorPrincipal["type"] | undefined)'; + return TYPE_RENAME_MAP[name] ?? null; } interface ImportRewriteResult { @@ -82,6 +585,8 @@ function rebuildImportStatement( importStmt: SgNode, globalEmittedRenamed: Set, unauthenticatedLocalNames: Set, + nullableInvokerAliasLocalNames: Set, + actorTypeAliasLocalNames: Set, ): ImportRewriteResult { const importText = importStmt.text(); const isImportType = /^\s*import\s+type\b/.test(importText); @@ -99,16 +604,23 @@ function rebuildImportStatement( const renamed = TYPE_RENAME_MAP[importedName]; if (renamed) { touched = true; - const finalLocal = aliasNode?.text() ?? renamed; + const dropsAliasForNullableInvoker = importedName === "TailorInvoker" && !!aliasNode; + const dropsAliasForActorType = importedName === "TailorActorType" && !!aliasNode; + const dropsAliasForExpandedType = dropsAliasForNullableInvoker || dropsAliasForActorType; + if (dropsAliasForNullableInvoker) nullableInvokerAliasLocalNames.add(localName); + if (dropsAliasForActorType) actorTypeAliasLocalNames.add(localName); + const finalLocal = dropsAliasForExpandedType ? renamed : (aliasNode?.text() ?? renamed); if (seenLocal.has(finalLocal)) continue; // Cross-statement dedupe for non-aliased renames so a file with // `import { TailorUser } from "@tailor-platform/sdk"` and // `import { TailorActor } from "@tailor-platform/sdk"` does not collapse to // two duplicate `import { TailorPrincipal } ...` lines. - if (!aliasNode && globalEmittedRenamed.has(renamed)) continue; + if ((!aliasNode || dropsAliasForExpandedType) && globalEmittedRenamed.has(renamed)) { + continue; + } seenLocal.add(finalLocal); - if (!aliasNode) globalEmittedRenamed.add(renamed); - const asPart = aliasNode ? ` as ${aliasNode.text()}` : ""; + if (!aliasNode || dropsAliasForExpandedType) globalEmittedRenamed.add(renamed); + const asPart = aliasNode && !dropsAliasForExpandedType ? ` as ${aliasNode.text()}` : ""; newSpecTexts.push(`${isTypeOnly ? "type " : ""}${renamed}${asPart}`); } else if (importedName === UNAUTHENTICATED) { touched = true; @@ -201,6 +713,17 @@ function patternBindsName(pat: SgNode, name: string): boolean { return false; } +function hasNestedPropertyPattern(pat: SgNode, name: string): boolean { + if (pat.kind() !== "object_pattern") return false; + for (const child of pat.children()) { + if (child.kind() !== "pair_pattern") continue; + const key = child.field("key"); + if (key?.text() !== name) continue; + if (child.field("value")?.kind() !== "identifier") return true; + } + return false; +} + function functionRebindsName(fn: SgNode, name: string): boolean { const single = fn.field("parameter"); if (single && patternBindsName(single, name)) return true; @@ -299,6 +822,80 @@ function collectAllShadowRanges(root: SgNode, name: string): Array<[number, numb return ranges; } +function hasUnshadowedIdentifierReference(root: SgNode, name: string): boolean { + const shadowRanges = collectAllShadowRanges(root, name); + const refs = root.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(name)}$` }, + }); + for (const ref of refs) { + if (!isInsideAnyRange(ref.range().start.index, shadowRanges)) return true; + } + return false; +} + +function hasPrincipalAssignmentTarget(root: SgNode, name: string): boolean { + const shadowRanges = collectAllShadowRanges(root, name); + const refs = root.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(name)}$` }, + }); + for (const ref of refs) { + const pos = ref.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + if (isAssignmentTargetReference(ref)) return true; + } + return false; +} + +function rewriteParseArgumentShorthands( + root: SgNode, + localName: string, + propertyName: string, + parseContext: SdkFieldParseContext, + edits: Edit[], +): void { + const shadowRanges = collectAllShadowRanges(root, localName); + const shortRefs = root.findAll({ + rule: { kind: "shorthand_property_identifier", regex: `^${escapeRegex(localName)}$` }, + }); + for (const ref of shortRefs) { + const pos = ref.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + if (!isSdkFieldParseArgumentShorthand(ref, parseContext)) continue; + edits.push(ref.replace(`${propertyName}: ${localName}`)); + } +} + +interface PrincipalLocalBinding { + name: string; + bindingStart: number; +} + +function guardPrincipalMemberAccesses( + root: SgNode, + binding: PrincipalLocalBinding, + edits: Edit[], +): void { + const refs = root.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(binding.name)}$` }, + }); + for (const ref of refs) { + if (!isOptionalizableMemberObject(ref)) continue; + const pos = ref.range().start.index; + if (isShadowedLocalReference(root, binding.name, pos, binding.bindingStart)) continue; + edits.push(ref.replace(`${binding.name}?`)); + } + + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const value = decl.field("value"); + if (!value || value.kind() !== "identifier" || value.text() !== binding.name) continue; + if (!isObjectDestructureInitializer(value)) continue; + const pos = value.range().start.index; + if (isShadowedLocalReference(root, binding.name, pos, binding.bindingStart)) continue; + edits.push(value.replace(`${binding.name} ?? {}`)); + } +} + function findResolverBodyArrow(call: SgNode): SgNode | null { const args = call.field("arguments"); if (!args) return null; @@ -318,6 +915,59 @@ function findResolverBodyArrow(call: SgNode): SgNode | null { return null; } +function* iterateNamespaceImportLocalNames(importStmt: SgNode): Generator { + const namespaceImports = importStmt.findAll({ rule: { kind: "namespace_import" } }); + for (const namespaceImport of namespaceImports) { + const localName = namespaceImport.children().find((c: SgNode) => c.kind() === "identifier"); + if (localName) yield localName.text(); + } +} + +function isUnshadowedNamespaceObject( + node: SgNode, + namespaceNames: Set, + root: SgNode, +): boolean { + if (node.kind() !== "identifier" || !namespaceNames.has(node.text())) return false; + return !isInsideAnyRange(node.range().start.index, collectAllShadowRanges(root, node.text())); +} + +function isSdkNamespaceQualifiedTypeIdentifier( + typeId: SgNode, + namespaceNames: Set, + root: SgNode, +): boolean { + if (typeId.kind() !== "type_identifier") return false; + const parent = typeId.parent(); + if (!parent || parent.kind() !== "nested_type_identifier") return false; + const namespaceObject = parent.children().find((c: SgNode) => c.kind() === "identifier"); + return !!namespaceObject && isUnshadowedNamespaceObject(namespaceObject, namespaceNames, root); +} + +function namespaceQualifiedTypeReplacement( + typeId: SgNode, + namespaceNames: Set, + root: SgNode, +): { target: SgNode; text: string } | null { + if (typeId.kind() !== "type_identifier") return null; + const parent = typeId.parent(); + if (!parent || parent.kind() !== "nested_type_identifier") return null; + const namespaceObject = parent.children().find((c: SgNode) => c.kind() === "identifier"); + if (!namespaceObject || !isUnshadowedNamespaceObject(namespaceObject, namespaceNames, root)) { + return null; + } + + const namespace = namespaceObject.text(); + if (typeId.text() === "TailorInvoker") { + return { target: parent, text: `(${namespace}.TailorPrincipal | null)` }; + } + if (typeId.text() === "TailorActorType") { + return { target: parent, text: `(${namespace}.TailorPrincipal["type"] | undefined)` }; + } + const renamed = TYPE_RENAME_MAP[typeId.text()]; + return renamed ? { target: typeId, text: renamed } : null; +} + /** * Look for any binding named `caller` in the resolver body or pattern. When * one exists, renaming `user` → `caller` would either shadow it, collide with @@ -343,22 +993,15 @@ function hasCallerBindingConflict(pattern: SgNode, body: SgNode): boolean { if (inner && inner.text() === "caller") return true; } } - const decls = body.findAll({ - rule: { - kind: "identifier", - regex: "^caller$", - inside: { kind: "variable_declarator" }, - }, - }); - if (decls.length > 0) return true; - const shortDecls = body.findAll({ - rule: { - kind: "shorthand_property_identifier_pattern", - regex: "^caller$", - inside: { kind: "variable_declarator" }, - }, - }); - if (shortDecls.length > 0) return true; + const decls = body.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of decls) { + const nameNode = decl.field("name"); + if (nameNode && patternBindsName(nameNode, "caller")) return true; + } + const functionDecls = body.findAll({ rule: { kind: "function_declaration" } }); + for (const fn of functionDecls) { + if (fn.field("name")?.text() === "caller") return true; + } for (const k of NESTED_FN_KINDS) { const fns = body.findAll({ rule: { kind: k } }); for (const fn of fns) { @@ -368,7 +1011,11 @@ function hasCallerBindingConflict(pattern: SgNode, body: SgNode): boolean { return false; } -function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { +function transformResolverBody( + arrowNode: SgNode, + edits: Edit[], + parseContext: SdkFieldParseContext, +): void { const params = arrowNode.field("parameters") ?? arrowNode.field("parameter") ?? @@ -397,20 +1044,41 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { if (!pattern) return; if (pattern.kind() === "object_pattern") { + if (hasNestedPropertyPattern(pattern, "user")) return; + if (hasPrincipalAssignmentTarget(body, "user")) return; if (hasCallerBindingConflict(pattern, body)) return; + const aliasRenamedUser = hasUnshadowedIdentifierReference(body, "caller"); + let aliasedShorthandUser = false; let renamedShorthandUser = false; + const principalAliasBindings: PrincipalLocalBinding[] = []; // Only iterate top-level pattern children so nested destructures like // `({ input: { user } })` are not mistaken for the resolver context user. for (const child of pattern.children()) { const kind = child.kind(); if (kind === "shorthand_property_identifier_pattern" && child.text() === "user") { - edits.push(child.replace("caller")); - renamedShorthandUser = true; + if (aliasRenamedUser) { + edits.push(child.replace("caller: user")); + aliasedShorthandUser = true; + principalAliasBindings.push({ + name: "user", + bindingStart: child.range().start.index, + }); + } else { + edits.push(child.replace("caller")); + renamedShorthandUser = true; + } } else if (kind === "pair_pattern") { const key = child.field("key"); if (key && key.text() === "user") { edits.push(key.replace("caller")); + const value = child.field("value"); + if (value?.kind() === "identifier") { + principalAliasBindings.push({ + name: value.text(), + bindingStart: value.range().start.index, + }); + } } } else if (kind === "object_assignment_pattern") { // `{ user = fallback }` — the inner shorthand is the binding; default @@ -419,11 +1087,23 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { .children() .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); if (inner && inner.text() === "user") { - edits.push(inner.replace("caller")); - renamedShorthandUser = true; + if (aliasRenamedUser) { + edits.push(inner.replace("caller: user")); + aliasedShorthandUser = true; + principalAliasBindings.push({ + name: "user", + bindingStart: inner.range().start.index, + }); + } else { + edits.push(inner.replace("caller")); + renamedShorthandUser = true; + } } } } + if (aliasedShorthandUser) { + rewriteParseArgumentShorthands(body, "user", "invoker", parseContext, edits); + } if (renamedShorthandUser) { // Use the broader shadow-range collector here so a nested arrow that // re-binds `user` as a parameter (e.g. `items.map((user) => user.id)`) @@ -434,7 +1114,7 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { for (const ref of refs) { const pos = ref.range().start.index; if (isInsideAnyRange(pos, shadowRanges)) continue; - edits.push(ref.replace("caller")); + edits.push(ref.replace(principalIdentifierReplacement(ref, "caller"))); } // Object literal shorthand (kind: `shorthand_property_identifier`, no // `_pattern` suffix) is both the key and the value. Rewriting it to @@ -448,9 +1128,18 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { for (const ref of shortRefs) { const pos = ref.range().start.index; if (isInsideAnyRange(pos, shadowRanges)) continue; - edits.push(ref.replace("user: caller")); + edits.push( + ref.replace( + isSdkFieldParseArgumentShorthand(ref, parseContext) + ? "invoker: caller" + : "user: caller", + ), + ); } } + for (const binding of principalAliasBindings) { + guardPrincipalMemberAccesses(body, binding, edits); + } return; } @@ -468,7 +1157,7 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { if (!(obj && obj.kind() === "identifier" && obj.text() === ctxName)) continue; const pos = obj.range().start.index; if (isInsideAnyRange(pos, ctxShadowRanges)) continue; - edits.push(propId.replace("caller")); + edits.push(propId.replace(principalPropertyReplacement(propId, "caller"))); } // Also rewrite destructures of the context, e.g. `const { user } = ctx;` → @@ -484,6 +1173,7 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { }, }, }); + const principalAliasBindings: PrincipalLocalBinding[] = []; for (const decl of ctxDestructures) { const pos = decl.range().start.index; if (isInsideAnyRange(pos, ctxShadowRanges)) continue; @@ -493,71 +1183,1765 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { const k = child.kind(); if (k === "shorthand_property_identifier_pattern" && child.text() === "user") { edits.push(child.replace("caller: user")); + principalAliasBindings.push({ + name: "user", + bindingStart: child.range().start.index, + }); } else if (k === "pair_pattern") { const key = child.field("key"); - if (key && key.text() === "user") { + const value = child.field("value"); + if (key && key.text() === "user" && value?.kind() === "identifier") { edits.push(key.replace("caller")); + principalAliasBindings.push({ + name: value.text(), + bindingStart: value.range().start.index, + }); } } } } + for (const binding of principalAliasBindings) { + guardPrincipalMemberAccesses(body, binding, edits); + } } -/** - * Migrate user/actor/invoker types and identifiers to the unified TailorPrincipal. - * - * - Renames `TailorUser` / `TailorActor` / `TailorInvoker` type references to `TailorPrincipal`. - * - Rewrites SDK imports (including the `/test` subpath) to use `TailorPrincipal` (deduped - * across statements) and drops `unauthenticatedTailorUser`. - * - Replaces standalone references to `unauthenticatedTailorUser` with `null`. Member-access - * forms like `unauthenticatedTailorUser.id` are left alone on purpose so the resulting TS - * error after the import is removed points the author at the broken access. - * - Renames `user` to `caller` for top-level destructured resolver bodies (`{ input, user }`), - * handles aliased pairs (`{ user: currentUser }`) by rewriting only the property name, and - * rewrites `.user` for non-destructured single-param bodies — respecting variable - * shadowing in both directions. - * @param source - TypeScript source text. - * @returns Transformed source or null when nothing matched. - */ -export default function transform(source: string): string | null { - if (!quickFilter(source)) return null; +function findMemberCallName(call: SgNode): string | null { + const fn = call.field("function"); + if (!fn || fn.kind() !== "member_expression") return null; + const property = fn.field("property"); + return property?.text() ?? null; +} - const tree = parse(Lang.TypeScript, source).root(); - const edits: Edit[] = []; +function findMemberCallObject(call: SgNode): SgNode | null { + const fn = call.field("function"); + if (!fn || fn.kind() !== "member_expression") return null; + return fn.field("object"); +} - const sdkImports = tree.findAll({ - rule: { - kind: "import_statement", - has: { kind: "string", regex: "^[\"']@tailor-platform/sdk(/test)?[\"']$" }, - }, - }); +function isFunctionNode(node: SgNode): boolean { + return ( + node.kind() === "arrow_function" || + node.kind() === "function_declaration" || + node.kind() === "function_expression" || + node.kind() === "method_definition" + ); +} - // Only rewrite type identifiers that are imported from the SDK without an - // alias. A local `import type { TailorUser } from './domain'` must stay alone - // even when the file also imports something else from the SDK. - const sdkRenameSourceNames = new Set(); - for (const importStmt of sdkImports) { - for (const { importedName, aliasNode } of iterateImportSpecs(importStmt)) { - if (TYPE_RENAME_MAP[importedName] && !aliasNode) { - sdkRenameSourceNames.add(importedName); +function propertyName(node: SgNode): string | null { + return node.field("key")?.text() ?? node.field("name")?.text() ?? null; +} + +function getFirstFunctionParam(fn: SgNode): SgNode | null { + const params = + fn.field("parameters") ?? + fn.field("parameter") ?? + fn.children().find((c: SgNode) => c.kind() === "formal_parameters"); + if (!params) return null; + if (params.kind() === "object_pattern" || params.kind() === "identifier") { + return params; + } + + return ( + params + .children() + .find( + (c: SgNode) => + c.kind() === "required_parameter" || + c.kind() === "optional_parameter" || + c.kind() === "identifier" || + c.kind() === "object_pattern", + ) ?? null + ); +} + +function getFunctionParamPattern(param: SgNode): SgNode | null { + if (param.kind() === "object_pattern" || param.kind() === "identifier") return param; + return param.field("pattern"); +} + +function findExecutorBodyFunctions(call: SgNode): SgNode[] { + const args = call.field("arguments"); + const objArg = args?.children().find((c: SgNode) => c.kind() === "object"); + if (!objArg) return []; + + const functions: SgNode[] = []; + const visitObject = (object: SgNode): void => { + for (const child of object.children()) { + if (child.kind() === "method_definition") { + if (propertyName(child) === "body") functions.push(child); + continue; + } + if (child.kind() !== "pair") continue; + + const value = child.field("value"); + if (!value) continue; + if (child.field("key")?.text() === "body" && isFunctionNode(value)) { + functions.push(value); + } else if (value.kind() === "object") { + visitObject(value); } } - } + }; - const typeIdents = tree.findAll({ - rule: { - kind: "type_identifier", - not: { inside: { kind: "import_statement" } }, - }, - }); - for (const id of typeIdents) { - if (!sdkRenameSourceNames.has(id.text())) continue; - const newName = TYPE_RENAME_MAP[id.text()]!; - edits.push(id.replace(newName)); + visitObject(objArg); + return functions; +} + +function transformExecutorBodyActorAccesses( + fn: SgNode, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + const param = getFirstFunctionParam(fn); + const pattern = param ? getFunctionParamPattern(param) : null; + const body = fn.field("body"); + if (!pattern || !body) return; + + if (pattern.kind() === "identifier") { + const ctxName = pattern.text(); + const ctxShadowRanges = collectCtxShadowRanges(body, ctxName, fn); + transformExecutorCtxActorAccesses( + body, + ctxName, + ctxShadowRanges, + edits, + transformedActorPropertyStarts, + ); + return; } - let importRemoved = false; - const globalEmittedRenamed = new Set(); + if (pattern.kind() !== "object_pattern") return; + + for (const child of pattern.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === "actor") { + transformActorBindingMemberAccesses( + body, + { name: "actor", bindingStart: child.range().start.index }, + edits, + transformedActorPropertyStarts, + ); + } else if (kind === "pair_pattern") { + const key = child.field("key"); + const value = child.field("value"); + if (key?.text() === "actor" && value?.kind() === "identifier") { + transformActorBindingMemberAccesses( + body, + { name: value.text(), bindingStart: value.range().start.index }, + edits, + transformedActorPropertyStarts, + ); + } + } + } +} + +interface LocalCallbackTypeBinding { + name: string; + declaration: SgNode; + bindingStart: number; + scope: [number, number]; +} + +interface CallbackTypeContext { + bindings: LocalCallbackTypeBinding[]; + transformedTypeStarts: Set; + transformedPrincipalTypeStarts: Set; + tailorUserTypeLocalNames: Set; + sdkNamespaceNames: Set; + sdkFieldRootNames: Set; + sdkFieldLocalBindings: SdkFieldLocalBinding[]; + root: SgNode; +} + +function nullableTailorUserTypeReplacement( + typeId: SgNode, + typeContext: CallbackTypeContext, +): string | null { + if (typeContext.tailorUserTypeLocalNames.has(typeId.text())) { + return typeId.text() === "TailorUser" ? "TailorPrincipal | null" : `${typeId.text()} | null`; + } + return isSdkNamespaceQualifiedTypeIdentifier( + typeId, + typeContext.sdkNamespaceNames, + typeContext.root, + ) + ? "TailorPrincipal | null" + : null; +} + +function transformObjectTypeUserProperty( + objectType: SgNode, + edits: Edit[], + typeContext: CallbackTypeContext, +): void { + for (const child of objectType.children()) { + if (child.kind() !== "property_signature") continue; + const name = child.field("name"); + if (name?.text() !== "user") continue; + edits.push(name.replace("invoker")); + + const typeAnnotation = child.field("type"); + if (!typeAnnotation || /\bnull\b/.test(typeAnnotation.text())) continue; + const typeIds = typeAnnotation.findAll({ rule: { kind: "type_identifier" } }); + for (const typeId of typeIds) { + const replacement = nullableTailorUserTypeReplacement(typeId, typeContext); + if (!replacement) continue; + typeContext.transformedPrincipalTypeStarts.add(typeId.range().start.index); + edits.push(typeId.replace(replacement)); + } + } +} + +function localCallbackTypeObject(declaration: SgNode): SgNode | null { + return ( + declaration + .children() + .find((c: SgNode) => c.kind() === "object_type" || c.kind() === "interface_body") ?? null + ); +} + +function collectLocalCallbackTypeBindings(root: SgNode): LocalCallbackTypeBinding[] { + const rootScope = localBindingRootScope(root); + const bindings: LocalCallbackTypeBinding[] = []; + + for (const kind of ["type_alias_declaration", "interface_declaration"]) { + const declarations = root.findAll({ rule: { kind } }); + for (const declaration of declarations) { + if (!localCallbackTypeObject(declaration)) continue; + const name = declaration.field("name"); + if (!name || (name.kind() !== "type_identifier" && name.kind() !== "identifier")) continue; + bindings.push({ + name: name.text(), + declaration, + bindingStart: name.range().start.index, + scope: enclosingScopeRange(declaration) ?? rootScope, + }); + } + } + + return bindings; +} + +function isShadowedLocalTypeReference( + root: SgNode, + name: string, + pos: number, + bindingStart: number, +): boolean { + for (const kind of ["type_alias_declaration", "interface_declaration"]) { + const declarations = root.findAll({ rule: { kind } }); + for (const declaration of declarations) { + const nameNode = declaration.field("name"); + if (!nameNode || nameNode.text() !== name) continue; + if (nameNode.range().start.index === bindingStart) continue; + const scope = enclosingScopeRange(declaration); + if (scope && rangeContains(scope, pos)) return true; + } + } + return false; +} + +function resolveLocalCallbackTypeBinding( + node: SgNode, + context: CallbackTypeContext, +): LocalCallbackTypeBinding | null { + const pos = node.range().start.index; + return ( + context.bindings.find( + (binding) => + binding.name === node.text() && + rangeContains(binding.scope, pos) && + !isShadowedLocalTypeReference(context.root, binding.name, pos, binding.bindingStart), + ) ?? null + ); +} + +function transformNamedPrincipalCallbackType( + typeName: SgNode, + edits: Edit[], + context: CallbackTypeContext, +): void { + const binding = resolveLocalCallbackTypeBinding(typeName, context); + if (!binding) return; + + const start = binding.declaration.range().start.index; + if (context.transformedTypeStarts.has(start)) return; + + const objectType = localCallbackTypeObject(binding.declaration); + if (!objectType) return; + context.transformedTypeStarts.add(start); + transformObjectTypeUserProperty(objectType, edits, context); +} + +function transformPrincipalCallbackParamType( + param: SgNode, + edits: Edit[], + typeContext?: CallbackTypeContext, +): void { + const typeAnnotation = param.field("type"); + const objectType = typeAnnotation?.children().find((c: SgNode) => c.kind() === "object_type"); + if (objectType) { + if (typeContext) { + transformObjectTypeUserProperty(objectType, edits, typeContext); + } + return; + } + + const typeName = typeAnnotation?.children().find((c: SgNode) => c.kind() === "type_identifier"); + if (typeName && typeContext) transformNamedPrincipalCallbackType(typeName, edits, typeContext); +} + +function transformPrincipalCallbackParam( + fn: SgNode, + edits: Edit[], + typeContext: CallbackTypeContext, +): void { + const param = getFirstFunctionParam(fn); + if (!param) return; + const pattern = getFunctionParamPattern(param); + const body = fn.field("body"); + if (!pattern || !body) return; + + if (pattern.kind() === "identifier") { + const ctxName = pattern.text(); + const ctxShadowRanges = collectCtxShadowRanges(body, ctxName, fn); + const propertyAccesses = body.findAll({ + rule: { kind: "property_identifier", regex: "^user$" }, + }); + for (const propId of propertyAccesses) { + const parent = propId.parent(); + if (!parent || parent.kind() !== "member_expression") continue; + const obj = parent.field("object"); + if (!(obj && obj.kind() === "identifier" && obj.text() === ctxName)) continue; + const pos = obj.range().start.index; + if (isInsideAnyRange(pos, ctxShadowRanges)) continue; + edits.push(propId.replace(principalPropertyReplacement(propId, "invoker"))); + } + + const ctxDestructures = body.findAll({ + rule: { + kind: "variable_declarator", + has: { + field: "value", + kind: "identifier", + regex: `^${escapeRegex(ctxName)}$`, + }, + }, + }); + const principalAliasBindings: PrincipalLocalBinding[] = []; + for (const decl of ctxDestructures) { + const pos = decl.range().start.index; + if (isInsideAnyRange(pos, ctxShadowRanges)) continue; + const pat = decl.field("name"); + if (!pat || pat.kind() !== "object_pattern") continue; + for (const child of pat.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === "user") { + edits.push(child.replace("invoker: user")); + principalAliasBindings.push({ + name: "user", + bindingStart: child.range().start.index, + }); + } else if (kind === "pair_pattern") { + const key = child.field("key"); + const value = child.field("value"); + if (key?.text() === "user" && value?.kind() === "identifier") { + edits.push(key.replace("invoker")); + principalAliasBindings.push({ + name: value.text(), + bindingStart: value.range().start.index, + }); + } + } + } + } + for (const binding of principalAliasBindings) { + guardPrincipalMemberAccesses(body, binding, edits); + } + transformPrincipalCallbackParamType(param, edits, typeContext); + return; + } + + if (pattern.kind() !== "object_pattern") return; + if (hasNestedPropertyPattern(pattern, "user")) return; + + let hasUserParamProperty = false; + let renamesBinding = false; + const principalAliasBindings: PrincipalLocalBinding[] = []; + for (const child of pattern.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === "user") { + hasUserParamProperty = true; + renamesBinding = true; + } else if (kind === "pair_pattern") { + const key = child.field("key"); + if (key?.text() === "user") { + hasUserParamProperty = true; + const value = child.field("value"); + if (value?.kind() === "identifier") { + principalAliasBindings.push({ + name: value.text(), + bindingStart: value.range().start.index, + }); + } + } + } else if (kind === "object_assignment_pattern") { + const inner = child + .children() + .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); + if (inner?.text() === "user") { + hasUserParamProperty = true; + renamesBinding = true; + } + } + } + + if (!hasUserParamProperty) return; + if (renamesBinding && hasPrincipalAssignmentTarget(body, "user")) return; + if (renamesBinding && patternBindsName(pattern, "invoker")) return; + transformPrincipalCallbackParamType(param, edits, typeContext); + + const aliasRenamedUser = + renamesBinding && + (collectAllShadowRanges(body, "invoker").length > 0 || + hasUnshadowedIdentifierReference(body, "invoker")); + + let aliasedShorthandUser = false; + let renamedShorthandUser = false; + for (const child of pattern.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === "user") { + if (aliasRenamedUser) { + edits.push(child.replace("invoker: user")); + aliasedShorthandUser = true; + principalAliasBindings.push({ + name: "user", + bindingStart: child.range().start.index, + }); + } else { + edits.push(child.replace("invoker")); + renamedShorthandUser = true; + } + } else if (kind === "pair_pattern") { + const key = child.field("key"); + if (key?.text() === "user") { + edits.push(key.replace("invoker")); + } + } else if (kind === "object_assignment_pattern") { + const inner = child + .children() + .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); + if (inner?.text() === "user") { + if (aliasRenamedUser) { + edits.push(inner.replace("invoker: user")); + aliasedShorthandUser = true; + principalAliasBindings.push({ + name: "user", + bindingStart: inner.range().start.index, + }); + } else { + edits.push(inner.replace("invoker")); + renamedShorthandUser = true; + } + } + } + } + + if (!renamedShorthandUser) { + if (aliasedShorthandUser) { + rewriteParseArgumentShorthands(body, "user", "invoker", typeContext, edits); + } + for (const binding of principalAliasBindings) { + guardPrincipalMemberAccesses(body, binding, edits); + } + return; + } + + const shadowRanges = collectAllShadowRanges(body, "user"); + const refs = body.findAll({ rule: { kind: "identifier", regex: "^user$" } }); + for (const ref of refs) { + const pos = ref.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + edits.push(ref.replace(principalReadReplacement(ref, "invoker"))); + } + + const shortRefs = body.findAll({ + rule: { kind: "shorthand_property_identifier", regex: "^user$" }, + }); + for (const ref of shortRefs) { + const pos = ref.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + edits.push( + ref.replace(isSdkFieldParseArgumentShorthand(ref, typeContext) ? "invoker" : "user: invoker"), + ); + } + + for (const binding of principalAliasBindings) { + guardPrincipalMemberAccesses(body, binding, edits); + } +} + +interface SdkFieldLocalBinding { + name: string; + bindingStart: number; + scope: [number, number]; +} + +function rangeContains([start, end]: [number, number], pos: number): boolean { + return pos >= start && pos < end; +} + +function isShadowedLocalReference( + root: SgNode, + name: string, + pos: number, + bindingStart: number, +): boolean { + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const nameNode = decl.field("name"); + if (!nameNode || !patternBindsName(nameNode, name)) continue; + const nameRange = nameNode.range(); + if (bindingStart >= nameRange.start.index && bindingStart < nameRange.end.index) continue; + const scope = enclosingScopeRange(decl); + if (scope && rangeContains(scope, pos)) return true; + } + + const functionDecls = root.findAll({ rule: { kind: "function_declaration" } }); + for (const fn of functionDecls) { + const nameNode = fn.field("name"); + if (!nameNode || nameNode.text() !== name) continue; + if (nameNode.range().start.index === bindingStart) continue; + const scope = enclosingScopeRange(fn); + if (scope && rangeContains(scope, pos)) return true; + } + + for (const kind of NESTED_FN_KINDS) { + const fns = root.findAll({ rule: { kind } }); + for (const fn of fns) { + if (!functionRebindsName(fn, name)) continue; + const range = fn.range(); + if (pos >= range.start.index && pos < range.end.index) return true; + } + } + + return false; +} + +function resolvesToSdkFieldLocal( + node: SgNode, + bindings: SdkFieldLocalBinding[], + root: SgNode, +): boolean { + if (node.kind() !== "identifier") return false; + const pos = node.range().start.index; + return bindings.some( + (binding) => + binding.name === node.text() && + rangeContains(binding.scope, pos) && + !isShadowedLocalReference(root, binding.name, pos, binding.bindingStart), + ); +} + +function isSdkFieldExpression( + node: SgNode, + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + root: SgNode, +): boolean { + if (node.kind() === "identifier") { + const pos = node.range().start.index; + const isSdkRoot = + sdkFieldRootNames.has(node.text()) && + !isInsideAnyRange(pos, collectAllShadowRanges(root, node.text())); + return isSdkRoot || resolvesToSdkFieldLocal(node, sdkFieldLocalBindings, root); + } + if (node.kind() === "member_expression") { + const object = node.field("object"); + return object + ? isSdkFieldExpression(object, sdkFieldRootNames, sdkFieldLocalBindings, root) + : false; + } + if (node.kind() === "call_expression") { + const object = findMemberCallObject(node); + return object + ? isSdkFieldExpression(object, sdkFieldRootNames, sdkFieldLocalBindings, root) + : false; + } + return false; +} + +function collectSdkFieldLocalBindings( + root: SgNode, + sdkFieldRootNames: Set, +): SdkFieldLocalBinding[] { + const bindings: SdkFieldLocalBinding[] = []; + let changed = true; + while (changed) { + changed = false; + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const name = decl.field("name"); + const value = decl.field("value"); + if (!name || name.kind() !== "identifier" || !value) continue; + const bindingStart = name.range().start.index; + if (bindings.some((binding) => binding.bindingStart === bindingStart)) continue; + if (!isSdkFieldExpression(value, sdkFieldRootNames, bindings, root)) continue; + const rootRange = root.range(); + const scope = enclosingScopeRange(decl) ?? [rootRange.start.index, rootRange.end.index]; + bindings.push({ name: name.text(), bindingStart, scope }); + changed = true; + } + } + return bindings; +} + +function isSdkFieldMemberCall( + call: SgNode, + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + root: SgNode, +): boolean { + const object = findMemberCallObject(call); + return object + ? isSdkFieldExpression(object, sdkFieldRootNames, sdkFieldLocalBindings, root) + : false; +} + +function collectSdkFieldRootNames(root: SgNode): Set { + const sdkFieldRootNames = new Set(); + const sdkImports = root.findAll({ + rule: { + kind: "import_statement", + has: { kind: "string", regex: "^[\"']@tailor-platform/sdk(/test)?[\"']$" }, + }, + }); + for (const importStmt of sdkImports) { + for (const namespaceName of iterateNamespaceImportLocalNames(importStmt)) { + sdkFieldRootNames.add(namespaceName); + } + for (const { importedName, localName } of iterateImportSpecs(importStmt)) { + if (importedName === "t" || importedName === "db") { + sdkFieldRootNames.add(localName); + } + } + } + return sdkFieldRootNames; +} + +interface LocalCallbackBinding { + name: string; + fn: SgNode; + bindingStart: number; + scope: [number, number]; +} + +interface LocalObjectBinding { + name: string; + object: SgNode; + bindingStart: number; + scope: [number, number]; +} + +function localBindingRootScope(root: SgNode): [number, number] { + const rootRange = root.range(); + return [rootRange.start.index, rootRange.end.index]; +} + +function collectLocalCallbackBindings(root: SgNode): LocalCallbackBinding[] { + const rootScope = localBindingRootScope(root); + const bindings: LocalCallbackBinding[] = []; + + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const name = decl.field("name"); + const value = decl.field("value"); + if (!name || name.kind() !== "identifier" || !value || !isFunctionNode(value)) continue; + bindings.push({ + name: name.text(), + fn: value, + bindingStart: name.range().start.index, + scope: enclosingScopeRange(decl) ?? rootScope, + }); + } + + const functionDecls = root.findAll({ rule: { kind: "function_declaration" } }); + for (const fn of functionDecls) { + const name = fn.field("name"); + if (!name || name.kind() !== "identifier") continue; + bindings.push({ + name: name.text(), + fn, + bindingStart: name.range().start.index, + scope: enclosingScopeRange(fn) ?? rootScope, + }); + } + + return bindings; +} + +function collectLocalObjectBindings(root: SgNode): LocalObjectBinding[] { + const rootScope = localBindingRootScope(root); + const bindings: LocalObjectBinding[] = []; + + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const name = decl.field("name"); + const value = decl.field("value"); + if (!name || name.kind() !== "identifier" || value?.kind() !== "object") continue; + bindings.push({ + name: name.text(), + object: value, + bindingStart: name.range().start.index, + scope: enclosingScopeRange(decl) ?? rootScope, + }); + } + + return bindings; +} + +function resolveLocalCallbackBinding( + node: SgNode, + bindings: LocalCallbackBinding[], + root: SgNode, +): LocalCallbackBinding | null { + if (node.kind() !== "identifier") return null; + const pos = node.range().start.index; + return ( + bindings.find( + (binding) => + binding.name === node.text() && + rangeContains(binding.scope, pos) && + !isShadowedLocalReference(root, binding.name, pos, binding.bindingStart), + ) ?? null + ); +} + +function resolveLocalObjectBinding( + node: SgNode, + bindings: LocalObjectBinding[], + root: SgNode, +): LocalObjectBinding | null { + if (node.kind() !== "identifier") return null; + const pos = node.range().start.index; + return ( + bindings.find( + (binding) => + binding.name === node.text() && + rangeContains(binding.scope, pos) && + !isShadowedLocalReference(root, binding.name, pos, binding.bindingStart), + ) ?? null + ); +} + +function transformPrincipalCallbackNode( + node: SgNode, + edits: Edit[], + callbackBindings: LocalCallbackBinding[], + transformedCallbackStarts: Set, + typeContext: CallbackTypeContext, + root: SgNode, +): boolean { + if (isFunctionNode(node)) { + transformPrincipalCallbackParam(node, edits, typeContext); + return true; + } + + const binding = resolveLocalCallbackBinding(node, callbackBindings, root); + if (!binding) return false; + + const start = binding.fn.range().start.index; + if (!transformedCallbackStarts.has(start)) { + transformedCallbackStarts.add(start); + transformPrincipalCallbackParam(binding.fn, edits, typeContext); + } + return true; +} + +function transformHookCallbackObject( + node: SgNode, + edits: Edit[], + callbackBindings: LocalCallbackBinding[], + transformedCallbackStarts: Set, + typeContext: CallbackTypeContext, + root: SgNode, +): void { + if (node.kind() !== "object") return; + for (const child of node.children()) { + if (child.kind() === "method_definition") { + const key = propertyName(child); + if (key === "create" || key === "update") { + transformPrincipalCallbackParam(child, edits, typeContext); + } + continue; + } + if (child.kind() !== "pair") continue; + const key = child.field("key")?.text(); + const value = child.field("value"); + if (!value) continue; + if (key === "create" || key === "update") { + const transformed = transformPrincipalCallbackNode( + value, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + if (!transformed && value.kind() === "object") { + transformHookCallbackObject( + value, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } + } else if (value.kind() === "object") { + transformHookCallbackObject( + value, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } + } +} + +function transformHookCallbackConfigNode( + node: SgNode, + edits: Edit[], + callbackBindings: LocalCallbackBinding[], + objectBindings: LocalObjectBinding[], + transformedCallbackStarts: Set, + transformedObjectStarts: Set, + typeContext: CallbackTypeContext, + root: SgNode, +): boolean { + if (node.kind() === "object") { + transformHookCallbackObject( + node, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + return true; + } + + const binding = resolveLocalObjectBinding(node, objectBindings, root); + if (!binding) return false; + + const start = binding.object.range().start.index; + if (!transformedObjectStarts.has(start)) { + transformedObjectStarts.add(start); + transformHookCallbackObject( + binding.object, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } + return true; +} + +function transformValidateCallbackNode( + node: SgNode, + edits: Edit[], + callbackBindings: LocalCallbackBinding[], + transformedCallbackStarts: Set, + typeContext: CallbackTypeContext, + root: SgNode, +): void { + if ( + transformPrincipalCallbackNode( + node, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ) + ) { + return; + } + + if (node.kind() === "array") { + for (const child of node.children()) { + transformValidateCallbackNode( + child, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } + return; + } + + if (node.kind() !== "object") return; + for (const child of node.children()) { + if (child.kind() === "method_definition") { + transformPrincipalCallbackParam(child, edits, typeContext); + continue; + } + if (child.kind() !== "pair") continue; + const value = child.field("value"); + if (value) { + transformValidateCallbackNode( + value, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } + } +} + +function transformPrincipalCallbacksInCall( + call: SgNode, + edits: Edit[], + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + callbackBindings: LocalCallbackBinding[], + objectBindings: LocalObjectBinding[], + transformedCallbackStarts: Set, + transformedObjectStarts: Set, + typeContext: CallbackTypeContext, + root: SgNode, +): void { + const memberName = findMemberCallName(call); + if (memberName !== "hooks" && memberName !== "validate") return; + if (!isSdkFieldMemberCall(call, sdkFieldRootNames, sdkFieldLocalBindings, root)) return; + + const args = call.field("arguments"); + if (!args) return; + if (memberName === "hooks") { + for (const arg of args.children()) { + if ( + transformHookCallbackConfigNode( + arg, + edits, + callbackBindings, + objectBindings, + transformedCallbackStarts, + transformedObjectStarts, + typeContext, + root, + ) + ) { + break; + } + } + return; + } + + for (const arg of args.children()) { + transformValidateCallbackNode( + arg, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } +} + +function transformParseArgsObject( + call: SgNode, + edits: Edit[], + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + root: SgNode, +): void { + const memberName = findMemberCallName(call); + if (memberName !== "parse") return; + if (!isSdkFieldMemberCall(call, sdkFieldRootNames, sdkFieldLocalBindings, root)) return; + + const args = call.field("arguments"); + const objArg = args?.children().find((c: SgNode) => c.kind() === "object"); + if (!objArg) return; + + for (const child of objArg.children()) { + const kind = child.kind(); + if (kind === "pair") { + const key = child.field("key"); + if (key?.text() === "user") edits.push(key.replace("invoker")); + } else if (kind === "shorthand_property_identifier" && child.text() === "user") { + edits.push(child.replace("invoker: user")); + } + } +} + +const KYSELY_PREDICATE_METHODS = new Set(["where", "having", "on"]); + +function excerptForLine(source: string, line: number): string { + const excerpt = (source.split(/\r?\n/)[line - 1] ?? "").trim(); + return excerpt.length > 160 ? `${excerpt.slice(0, 157)}...` : excerpt; +} + +function addReviewFinding( + findings: LlmReviewFinding[], + seen: Set, + source: string, + file: string, + node: SgNode, + message: string, +): void { + const line = node.range().start.line + 1; + const excerpt = excerptForLine(source, line); + const key = `${file}:${line}:${message}:${excerpt}`; + if (seen.has(key)) return; + seen.add(key); + findings.push({ file, line, message, excerpt }); +} + +interface ReviewPrincipalBinding { + name: string; + bindingStart: number; + scope: [number, number]; + shadowRoot?: SgNode; +} + +function resolvesToReviewBinding( + node: SgNode, + bindings: ReviewPrincipalBinding[], + root: SgNode, +): boolean { + if (node.kind() !== "identifier") return false; + const pos = node.range().start.index; + return bindings.some((binding) => { + if (binding.name !== node.text() || !rangeContains(binding.scope, pos)) return false; + if (binding.shadowRoot) { + return !isInsideAnyRange(pos, collectAllShadowRanges(binding.shadowRoot, binding.name)); + } + return !isShadowedLocalReference(root, binding.name, pos, binding.bindingStart); + }); +} + +function isReviewContextCallerMemberExpression( + node: SgNode, + contextBindings: ReviewPrincipalBinding[], + root: SgNode, +): boolean { + if (node.kind() !== "member_expression") return false; + if (node.field("property")?.text() !== "caller") return false; + const object = node.field("object"); + return object ? resolvesToReviewBinding(object, contextBindings, root) : false; +} + +function isPrincipalOptionalMemberExpression( + node: SgNode, + principalBindings: ReviewPrincipalBinding[], + contextBindings: ReviewPrincipalBinding[], + root: SgNode, +): boolean { + if (node.kind() !== "member_expression") return false; + if (!node.children().some((child) => child.kind() === "optional_chain")) return false; + const object = node.field("object"); + if (!object) return false; + if (object.kind() === "identifier") { + return resolvesToReviewBinding(object, principalBindings, root); + } + return isReviewContextCallerMemberExpression(object, contextBindings, root); +} + +function isDirectPrincipalExpression( + node: SgNode, + principalBindings: ReviewPrincipalBinding[], + contextBindings: ReviewPrincipalBinding[], + root: SgNode, +): boolean { + if (resolvesToReviewBinding(node, principalBindings, root)) return true; + return isReviewContextCallerMemberExpression(node, contextBindings, root); +} + +function nodeContainsArgumentPrincipalOptionalAccess( + node: SgNode, + principalBindings: ReviewPrincipalBinding[], + contextBindings: ReviewPrincipalBinding[], + safePrincipalRanges: Array<[number, number]>, + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + root: SgNode, +): boolean { + const nodeSafePrincipalRanges = + node.kind() === "call_expression" + ? [ + ...safePrincipalRanges, + ...collectSafeNullablePrincipalArgumentRanges( + node, + sdkFieldRootNames, + sdkFieldLocalBindings, + root, + ), + ] + : safePrincipalRanges; + if (isInsideAnyRange(node.range().start.index, nodeSafePrincipalRanges)) return false; + if (isFunctionNode(node)) return false; + if (isDirectPrincipalExpression(node, principalBindings, contextBindings, root)) return true; + if (isPrincipalOptionalMemberExpression(node, principalBindings, contextBindings, root)) + return true; + return node + .children() + .some((child) => + nodeContainsArgumentPrincipalOptionalAccess( + child, + principalBindings, + contextBindings, + nodeSafePrincipalRanges, + sdkFieldRootNames, + sdkFieldLocalBindings, + root, + ), + ); +} + +function reviewCallName(call: SgNode): string { + const fn = call.field("function"); + if (!fn) return "a call"; + if (fn.kind() === "identifier") return `${fn.text()}()`; + if (fn.kind() === "member_expression") { + const property = fn.field("property"); + if (property) return `${property.text()}()`; + } + return "a call"; +} + +function collectParseInvokerValueRanges(call: SgNode): Array<[number, number]> { + const args = call.field("arguments"); + const objectArg = args?.children().find((child) => child.kind() === "object"); + if (!objectArg) return []; + + const ranges: Array<[number, number]> = []; + for (const child of objectArg.children()) { + if (child.kind() === "shorthand_property_identifier" && child.text() === "invoker") { + const range = child.range(); + ranges.push([range.start.index, range.end.index]); + continue; + } + + if (child.kind() !== "pair") continue; + const key = child.field("key"); + if (key?.text() !== "invoker") continue; + const value = child.field("value"); + if (!value) continue; + const range = value.range(); + ranges.push([range.start.index, range.end.index]); + } + return ranges; +} + +function collectSafeNullablePrincipalArgumentRanges( + call: SgNode, + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + root: SgNode, +): Array<[number, number]> { + if (findMemberCallName(call) !== "parse") return []; + if (!isSdkFieldMemberCall(call, sdkFieldRootNames, sdkFieldLocalBindings, root)) return []; + return collectParseInvokerValueRanges(call); +} + +function collectNullableCallerReviewFindings( + root: SgNode, + source: string, + file: string, + principalBindings: ReviewPrincipalBinding[], + contextBindings: ReviewPrincipalBinding[], + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + findings: LlmReviewFinding[], + seen: Set, +): void { + const calls = root.findAll({ rule: { kind: "call_expression" } }); + for (const call of calls) { + const args = call.field("arguments"); + const safePrincipalRanges = collectSafeNullablePrincipalArgumentRanges( + call, + sdkFieldRootNames, + sdkFieldLocalBindings, + root, + ); + const nullableArg = args + ?.children() + .find((child) => + nodeContainsArgumentPrincipalOptionalAccess( + child, + principalBindings, + contextBindings, + safePrincipalRanges, + sdkFieldRootNames, + sdkFieldLocalBindings, + root, + ), + ); + if (!nullableArg) continue; + + const memberName = findMemberCallName(call); + if (memberName && KYSELY_PREDICATE_METHODS.has(memberName)) { + addReviewFinding( + findings, + seen, + source, + file, + nullableArg, + "Nullable caller value is used as a Kysely predicate value.", + ); + continue; + } + + addReviewFinding( + findings, + seen, + source, + file, + nullableArg, + `Nullable caller value is passed as a non-null argument to ${reviewCallName(call)}.`, + ); + } +} + +function functionIdentifierParamName(fn: SgNode): string | null { + const param = getFirstFunctionParam(fn); + const pattern = param ? getFunctionParamPattern(param) : null; + return pattern?.kind() === "identifier" ? pattern.text() : null; +} + +function objectPatternTopLevelPropertyBindingName( + pattern: SgNode, + propertyName: string, +): string | null { + if (pattern.kind() !== "object_pattern") return null; + for (const child of pattern.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === propertyName) { + return child.text(); + } + if (kind === "pair_pattern" && child.field("key")?.text() === propertyName) { + const value = child.field("value"); + return value?.kind() === "identifier" ? value.text() : propertyName; + } + if (kind === "object_assignment_pattern") { + const inner = child + .children() + .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); + if (inner?.text() === propertyName) return inner.text(); + } + } + return null; +} + +function objectPatternHasTopLevelProperty(pattern: SgNode, propertyName: string): boolean { + return objectPatternTopLevelPropertyBindingName(pattern, propertyName) !== null; +} + +function functionReadsContextUser(fn: SgNode, contextName: string): boolean { + const body = fn.field("body"); + if (!body) return false; + const shadowRanges = collectCtxShadowRanges(body, contextName, fn); + const userProperties = body.findAll({ + rule: { kind: "property_identifier", regex: "^user$" }, + }); + for (const propId of userProperties) { + const parent = propId.parent(); + if (!parent || parent.kind() !== "member_expression") continue; + const object = parent.field("object"); + if (!object || object.kind() !== "identifier" || object.text() !== contextName) continue; + if (isInsideAnyRange(object.range().start.index, shadowRanges)) continue; + return true; + } + + const destructures = body.findAll({ + rule: { + kind: "variable_declarator", + has: { + field: "value", + kind: "identifier", + regex: `^${escapeRegex(contextName)}$`, + }, + }, + }); + for (const decl of destructures) { + const value = decl.field("value"); + if (!value || isInsideAnyRange(value.range().start.index, shadowRanges)) continue; + const name = decl.field("name"); + if (name && objectPatternHasTopLevelProperty(name, "user")) return true; + } + return false; +} + +function functionContextUserReadExpression(fn: SgNode): string | null { + const param = getFirstFunctionParam(fn); + const pattern = param ? getFunctionParamPattern(param) : null; + if (!pattern) return null; + if (pattern.kind() === "identifier") { + return functionReadsContextUser(fn, pattern.text()) ? `${pattern.text()}.user` : null; + } + return objectPatternTopLevelPropertyBindingName(pattern, "user"); +} + +type ContextUserHelperBinding = LocalCallbackBinding & { contextUserExpression: string }; + +function collectContextUserHelperBindings(root: SgNode): ContextUserHelperBinding[] { + return collectLocalCallbackBindings(root).flatMap((binding) => { + const contextUserExpression = functionContextUserReadExpression(binding.fn); + if (!contextUserExpression) return []; + return [{ ...binding, contextUserExpression }]; + }); +} + +function resolveContextUserHelperBinding( + node: SgNode, + bindings: ContextUserHelperBinding[], + root: SgNode, +): ContextUserHelperBinding | null { + if (node.kind() !== "identifier") return null; + const pos = node.range().start.index; + return ( + bindings.find( + (binding) => + binding.name === node.text() && + rangeContains(binding.scope, pos) && + !isShadowedLocalReference(root, binding.name, pos, binding.bindingStart), + ) ?? null + ); +} + +interface ResolverContextBody { + arrow: SgNode; + body: SgNode; + contextName: string; +} + +function addResolverContextBody(arrow: SgNode, bodies: ResolverContextBody[]): void { + const paramName = functionIdentifierParamName(arrow); + const body = arrow.field("body"); + if (!paramName || !body) return; + bodies.push({ arrow, body, contextName: paramName }); +} + +function collectResolverBodyArrows(root: SgNode): SgNode[] { + const sdkImports = root.findAll({ + rule: { + kind: "import_statement", + has: { kind: "string", regex: "^[\"']@tailor-platform/sdk(/test)?[\"']$" }, + }, + }); + const createResolverLocalNames = new Set(); + const sdkNamespaceNames = new Set(); + for (const importStmt of sdkImports) { + for (const namespaceName of iterateNamespaceImportLocalNames(importStmt)) { + sdkNamespaceNames.add(namespaceName); + } + for (const { importedName, localName } of iterateImportSpecs(importStmt)) { + if (importedName === "createResolver") createResolverLocalNames.add(localName); + } + } + + const arrows: SgNode[] = []; + for (const localName of createResolverLocalNames) { + const shadowRanges = collectAllShadowRanges(root, localName); + const calls = root.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "identifier", + regex: `^${escapeRegex(localName)}$`, + }, + }, + }); + for (const call of calls) { + const callee = call.field("function"); + if (!callee || isInsideAnyRange(callee.range().start.index, shadowRanges)) continue; + const arrow = findResolverBodyArrow(call); + if (arrow) arrows.push(arrow); + } + } + + for (const namespaceName of sdkNamespaceNames) { + const shadowRanges = collectAllShadowRanges(root, namespaceName); + const calls = root.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "member_expression", + has: { + field: "property", + kind: "property_identifier", + regex: "^createResolver$", + }, + }, + }, + }); + for (const call of calls) { + const callee = call.field("function"); + const object = callee?.field("object"); + if (!object || object.kind() !== "identifier" || object.text() !== namespaceName) continue; + if (isInsideAnyRange(object.range().start.index, shadowRanges)) continue; + const arrow = findResolverBodyArrow(call); + if (arrow) arrows.push(arrow); + } + } + return arrows; +} + +function collectResolverContextBodies(resolverBodyArrows: SgNode[]): ResolverContextBody[] { + const bodies: ResolverContextBody[] = []; + for (const arrow of resolverBodyArrows) { + addResolverContextBody(arrow, bodies); + } + return bodies; +} + +function collectResolverContextBindings( + root: SgNode, + resolverBodyArrows: SgNode[], +): ReviewPrincipalBinding[] { + const bindings: ReviewPrincipalBinding[] = []; + const rootRange = root.range(); + const rootScope: [number, number] = [rootRange.start.index, rootRange.end.index]; + + for (const arrow of resolverBodyArrows) { + const param = getFirstFunctionParam(arrow); + const pattern = param ? getFunctionParamPattern(param) : null; + const body = arrow.field("body"); + if (!pattern || pattern.kind() !== "identifier" || !body) continue; + + const bodyRange = body.range(); + bindings.push({ + name: pattern.text(), + bindingStart: pattern.range().start.index, + scope: [bodyRange.start.index, bodyRange.end.index], + shadowRoot: body, + }); + } + + let changed = true; + while (changed) { + changed = false; + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const name = decl.field("name"); + const value = decl.field("value"); + if (!name || name.kind() !== "identifier" || !value) continue; + + const bindingStart = name.range().start.index; + if (bindings.some((binding) => binding.bindingStart === bindingStart)) continue; + if (!resolvesToReviewBinding(value, bindings, root)) continue; + + bindings.push({ + name: name.text(), + bindingStart, + scope: enclosingScopeRange(decl) ?? rootScope, + }); + changed = true; + } + } + + return bindings; +} + +function collectCallerPatternBindings( + pattern: SgNode, + scope: [number, number], + bindings: ReviewPrincipalBinding[], + shadowRoot?: SgNode, +): void { + if (pattern.kind() !== "object_pattern") return; + for (const child of pattern.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === "caller") { + bindings.push({ name: "caller", bindingStart: child.range().start.index, scope, shadowRoot }); + } else if (kind === "pair_pattern") { + const key = child.field("key"); + const value = child.field("value"); + if (key?.text() === "caller" && value?.kind() === "identifier") { + bindings.push({ + name: value.text(), + bindingStart: value.range().start.index, + scope, + shadowRoot, + }); + } + } else if (kind === "object_assignment_pattern") { + const inner = child + .children() + .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); + if (inner?.text() === "caller") { + bindings.push({ + name: "caller", + bindingStart: inner.range().start.index, + scope, + shadowRoot, + }); + } + } + } +} + +function collectResolverPrincipalBindings( + root: SgNode, + contextBindings: ReviewPrincipalBinding[], + resolverBodyArrows: SgNode[], +): ReviewPrincipalBinding[] { + const bindings: ReviewPrincipalBinding[] = []; + for (const arrow of resolverBodyArrows) { + const param = getFirstFunctionParam(arrow); + const pattern = param ? getFunctionParamPattern(param) : null; + const body = arrow.field("body"); + if (!pattern || !body) continue; + const bodyRange = body.range(); + const bodyScope: [number, number] = [bodyRange.start.index, bodyRange.end.index]; + + if (pattern.kind() === "object_pattern") { + collectCallerPatternBindings(pattern, bodyScope, bindings, body); + continue; + } + + if (pattern.kind() !== "identifier") continue; + + const declarators = body.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const name = decl.field("name"); + const value = decl.field("value"); + if (!name || !value) continue; + + if (resolvesToReviewBinding(value, contextBindings, root)) { + collectCallerPatternBindings(name, enclosingScopeRange(decl) ?? bodyScope, bindings); + continue; + } + + if (name.kind() !== "identifier") continue; + const bindingStart = name.range().start.index; + if (bindings.some((binding) => binding.bindingStart === bindingStart)) continue; + if ( + !isReviewContextCallerMemberExpression(value, contextBindings, root) && + !resolvesToReviewBinding(value, bindings, root) + ) { + continue; + } + + bindings.push({ + name: name.text(), + bindingStart, + scope: enclosingScopeRange(decl) ?? bodyScope, + }); + } + } + return bindings; +} + +function firstIdentifierArgument(call: SgNode): SgNode | null { + const args = call.field("arguments"); + if (!args) return null; + for (const child of args.children()) { + if (child.kind() === "(" || child.kind() === ")" || child.kind() === ",") continue; + return child.kind() === "identifier" ? child : null; + } + return null; +} + +function collectContextUserHelperReviewFindings( + root: SgNode, + source: string, + file: string, + resolverBodyArrows: SgNode[], + findings: LlmReviewFinding[], + seen: Set, +): void { + const helperBindings = collectContextUserHelperBindings(root); + if (helperBindings.length === 0) return; + + const reportedDefinitions = new Set(); + for (const { arrow, body, contextName } of collectResolverContextBodies(resolverBodyArrows)) { + const shadowRanges = collectCtxShadowRanges(body, contextName, arrow); + const calls = body.findAll({ rule: { kind: "call_expression" } }); + for (const call of calls) { + if (isInsideAnyRange(call.range().start.index, shadowRanges)) continue; + const arg = firstIdentifierArgument(call); + if (!arg || arg.text() !== contextName) continue; + const callee = call.field("function"); + if (!callee) continue; + const helper = resolveContextUserHelperBinding(callee, helperBindings, root); + if (!helper) continue; + + if (!reportedDefinitions.has(helper.bindingStart)) { + reportedDefinitions.add(helper.bindingStart); + addReviewFinding( + findings, + seen, + source, + file, + helper.fn, + `Helper adapter ${helper.name} reads ${helper.contextUserExpression} and needs v2 caller/invoker semantics.`, + ); + } + addReviewFinding( + findings, + seen, + source, + file, + call, + `${helper.name}(${contextName}) passes an SDK resolver context into a helper that reads ${helper.contextUserExpression}.`, + ); + } + } +} + +export function reviewFindings( + source: string, + _filePath: string, + relativePath: string, +): LlmReviewFinding[] { + if (!source.includes("?.") && !source.includes("user") && !source.includes("caller")) { + return []; + } + + let root: SgNode; + try { + root = parse(Lang.TypeScript, source).root(); + } catch { + return []; + } + + const findings: LlmReviewFinding[] = []; + const seen = new Set(); + const resolverBodyArrows = collectResolverBodyArrows(root); + const contextBindings = collectResolverContextBindings(root, resolverBodyArrows); + const principalBindings = collectResolverPrincipalBindings( + root, + contextBindings, + resolverBodyArrows, + ); + const sdkFieldRootNames = collectSdkFieldRootNames(root); + const sdkFieldLocalBindings = collectSdkFieldLocalBindings(root, sdkFieldRootNames); + collectNullableCallerReviewFindings( + root, + source, + relativePath, + principalBindings, + contextBindings, + sdkFieldRootNames, + sdkFieldLocalBindings, + findings, + seen, + ); + collectContextUserHelperReviewFindings( + root, + source, + relativePath, + resolverBodyArrows, + findings, + seen, + ); + return findings; +} + +/** + * Migrate user/actor/invoker types and identifiers to the unified TailorPrincipal. + * + * - Renames `TailorUser` / `TailorActor` / `TailorInvoker` type references to `TailorPrincipal`. + * - Rewrites SDK imports (including the `/test` subpath) to use `TailorPrincipal` (deduped + * across statements) and drops `unauthenticatedTailorUser`. + * - Replaces standalone references to `unauthenticatedTailorUser` with `null`. Member-access + * forms like `unauthenticatedTailorUser.id` are left alone on purpose so the resulting TS + * error after the import is removed points the author at the broken access. + * - Rewrites actor member accesses from `userId` / `userType` to `id` / `type`. + * - Renames `user` to `caller` for top-level destructured resolver bodies (`{ input, user }`), + * handles aliased pairs (`{ user: currentUser }`) by rewriting only the property name, and + * rewrites `.user` for non-destructured single-param bodies — respecting variable + * shadowing in both directions. + * - Renames TailorDB hook/validator callback `user` parameters to `invoker`, and rewrites + * `.parse({ user: ... })` arguments to `.parse({ invoker: ... })`. + * @param source - TypeScript source text. + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string): string | null { + if (!quickFilter(source)) return null; + + const tree = parse(Lang.TypeScript, source).root(); + const edits: Edit[] = []; + + const sdkImports = tree.findAll({ + rule: { + kind: "import_statement", + has: { kind: "string", regex: "^[\"']@tailor-platform/sdk(/test)?[\"']$" }, + }, + }); + + // Only rewrite type identifiers that are imported from the SDK without an + // alias. A local `import type { TailorUser } from './domain'` must stay alone + // even when the file also imports something else from the SDK. + const sdkRenameSourceNames = new Set(); + const tailorUserTypeLocalNames = new Set(); + const nullableInvokerAliasLocalNames = new Set(); + const actorTypeAliasLocalNames = new Set(); + const actorTypeLocalNames = new Set(); + const actorTypeValueLocalNames = new Set(); + const sdkNamespaceNames = new Set(); + for (const importStmt of sdkImports) { + for (const namespaceName of iterateNamespaceImportLocalNames(importStmt)) { + sdkNamespaceNames.add(namespaceName); + } + for (const { importedName, aliasNode, localName } of iterateImportSpecs(importStmt)) { + if (TYPE_RENAME_MAP[importedName] && !aliasNode) { + sdkRenameSourceNames.add(importedName); + } + if (importedName === "TailorUser") { + tailorUserTypeLocalNames.add(localName); + } + if (importedName === "TailorActor") { + actorTypeLocalNames.add(localName); + } + if (importedName === "TailorActorType") { + actorTypeValueLocalNames.add(localName); + } + if (importedName === "TailorInvoker" && aliasNode) { + nullableInvokerAliasLocalNames.add(localName); + } + if (importedName === "TailorActorType" && aliasNode) { + actorTypeAliasLocalNames.add(localName); + } + } + } + + const transformedActorPropertyStarts = new Set(); + transformTailorActorTypedMemberAccesses( + tree, + actorTypeLocalNames, + sdkNamespaceNames, + edits, + transformedActorPropertyStarts, + ); + transformTailorActorTypeInitializerLiterals( + tree, + actorTypeValueLocalNames, + sdkNamespaceNames, + edits, + ); + transformTailorActorTypeBindingComparisons( + tree, + actorTypeValueLocalNames, + sdkNamespaceNames, + edits, + ); + + let importRemoved = false; + const globalEmittedRenamed = new Set(); // Populated only with names actually imported from the SDK (canonical or // alias). A file with a local `unauthenticatedTailorUser` declaration that // doesn't come from `@tailor-platform/sdk` is intentionally not rewritten. @@ -567,6 +2951,8 @@ export default function transform(source: string): string | null { importStmt, globalEmittedRenamed, unauthenticatedLocalNames, + nullableInvokerAliasLocalNames, + actorTypeAliasLocalNames, ); if (!touched) continue; edits.push(importStmt.replace(newText)); @@ -595,13 +2981,24 @@ export default function transform(source: string): string | null { // and unrelated local helpers named `createResolver` (when the SDK import // does not actually bring `createResolver` in) are not. const createResolverLocalNames = new Set(); + const createExecutorLocalNames = new Set(); + const sdkFieldRootNames = collectSdkFieldRootNames(tree); for (const importStmt of sdkImports) { for (const { importedName, localName } of iterateImportSpecs(importStmt)) { if (importedName === "createResolver") { createResolverLocalNames.add(localName); } + if (importedName === "createExecutor") { + createExecutorLocalNames.add(localName); + } } } + const sdkFieldLocalBindings = collectSdkFieldLocalBindings(tree, sdkFieldRootNames); + const parseContext: SdkFieldParseContext = { + sdkFieldRootNames, + sdkFieldLocalBindings, + root: tree, + }; for (const localName of createResolverLocalNames) { const shadowRanges = collectAllShadowRanges(tree, localName); const calls = tree.findAll({ @@ -620,8 +3017,139 @@ export default function transform(source: string): string | null { const pos = callee.range().start.index; if (isInsideAnyRange(pos, shadowRanges)) continue; const arrow = findResolverBodyArrow(call); - if (arrow) transformResolverBody(arrow, edits); + if (arrow) transformResolverBody(arrow, edits, parseContext); + } + } + for (const namespaceName of sdkNamespaceNames) { + const shadowRanges = collectAllShadowRanges(tree, namespaceName); + const calls = tree.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "member_expression", + has: { + field: "property", + kind: "property_identifier", + regex: "^createResolver$", + }, + }, + }, + }); + for (const call of calls) { + const callee = call.field("function"); + const object = callee?.field("object"); + if (!object || object.kind() !== "identifier" || object.text() !== namespaceName) continue; + const pos = object.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + const arrow = findResolverBodyArrow(call); + if (arrow) transformResolverBody(arrow, edits, parseContext); + } + } + for (const localName of createExecutorLocalNames) { + const shadowRanges = collectAllShadowRanges(tree, localName); + const calls = tree.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "identifier", + regex: `^${escapeRegex(localName)}$`, + }, + }, + }); + for (const call of calls) { + const callee = call.field("function"); + if (!callee) continue; + const pos = callee.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + for (const fn of findExecutorBodyFunctions(call)) { + transformExecutorBodyActorAccesses(fn, edits, transformedActorPropertyStarts); + } + } + } + for (const namespaceName of sdkNamespaceNames) { + const shadowRanges = collectAllShadowRanges(tree, namespaceName); + const calls = tree.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "member_expression", + has: { + field: "property", + kind: "property_identifier", + regex: "^createExecutor$", + }, + }, + }, + }); + for (const call of calls) { + const callee = call.field("function"); + const object = callee?.field("object"); + if (!object || object.kind() !== "identifier" || object.text() !== namespaceName) continue; + const pos = object.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + for (const fn of findExecutorBodyFunctions(call)) { + transformExecutorBodyActorAccesses(fn, edits, transformedActorPropertyStarts); + } + } + } + transformActorTypeComparisonLiterals(tree, edits, transformedActorPropertyStarts); + + const callbackBindings = collectLocalCallbackBindings(tree); + const objectBindings = collectLocalObjectBindings(tree); + const typeContext: CallbackTypeContext = { + bindings: collectLocalCallbackTypeBindings(tree), + transformedTypeStarts: new Set(), + transformedPrincipalTypeStarts: new Set(), + tailorUserTypeLocalNames, + sdkNamespaceNames, + sdkFieldRootNames, + sdkFieldLocalBindings, + root: tree, + }; + const transformedCallbackStarts = new Set(); + const transformedObjectStarts = new Set(); + const memberCalls = tree.findAll({ rule: { kind: "call_expression" } }); + for (const call of memberCalls) { + transformPrincipalCallbacksInCall( + call, + edits, + sdkFieldRootNames, + sdkFieldLocalBindings, + callbackBindings, + objectBindings, + transformedCallbackStarts, + transformedObjectStarts, + typeContext, + tree, + ); + transformParseArgsObject(call, edits, sdkFieldRootNames, sdkFieldLocalBindings, tree); + } + + const typeIdents = tree.findAll({ + rule: { + kind: "type_identifier", + not: { inside: { kind: "import_statement" } }, + }, + }); + for (const id of typeIdents) { + if (typeContext.transformedPrincipalTypeStarts.has(id.range().start.index)) continue; + const qualifiedReplacement = namespaceQualifiedTypeReplacement(id, sdkNamespaceNames, tree); + if (qualifiedReplacement) { + edits.push(qualifiedReplacement.target.replace(qualifiedReplacement.text)); + continue; } + const newName = sdkRenameSourceNames.has(id.text()) + ? renamedTypeIdentifierText(id.text()) + : nullableInvokerAliasLocalNames.has(id.text()) + ? renamedTypeIdentifierText("TailorInvoker") + : actorTypeAliasLocalNames.has(id.text()) + ? renamedTypeIdentifierText("TailorActorType") + : null; + if (!newName) continue; + edits.push(id.replace(newName)); } if (edits.length === 0) return null; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access-local/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access-local/input.ts new file mode 100644 index 000000000..18185eaf1 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access-local/input.ts @@ -0,0 +1,18 @@ +import { createExecutor } from "@tailor-platform/sdk"; + +const actor = { + userId: "domain-user", + userType: "performer", +}; + +export const domainActor = { + id: actor.userId, + type: actor.userType, +}; + +export const onEvent = createExecutor({ + operation: { + kind: "function", + body: () => domainActor, + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/expected.ts new file mode 100644 index 000000000..5d549ee20 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/expected.ts @@ -0,0 +1,29 @@ +import { createExecutor } from "@tailor-platform/sdk"; + +export const onEvent = createExecutor({ + operation: { + kind: "function", + async body(args) { + let label = "unknown"; + switch (args.actor?.type) { + case "user": + label = "user"; + break; + case "machine_user": + label = "machine"; + break; + case undefined: + label = "missing"; + break; + } + return { + id: args.actor?.id, + type: args.actor?.type, + label, + isUser: args.actor?.type === "user", + isMachine: args.actor?.type === "machine_user", + isUnspecified: args.actor?.type === undefined, + }; + }, + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/input.ts new file mode 100644 index 000000000..94400e65b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/input.ts @@ -0,0 +1,29 @@ +import { createExecutor } from "@tailor-platform/sdk"; + +export const onEvent = createExecutor({ + operation: { + kind: "function", + async body(args) { + let label = "unknown"; + switch (args.actor?.userType) { + case "USER_TYPE_USER": + label = "user"; + break; + case "USER_TYPE_MACHINE_USER": + label = "machine"; + break; + case "USER_TYPE_UNSPECIFIED": + label = "missing"; + break; + } + return { + id: args.actor?.userId, + type: args.actor?.userType, + label, + isUser: args.actor?.userType === "USER_TYPE_USER", + isMachine: args.actor?.userType === "USER_TYPE_MACHINE_USER", + isUnspecified: args.actor?.userType === "USER_TYPE_UNSPECIFIED", + }; + }, + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/expected.ts index 696045d1a..fc945343b 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/expected.ts @@ -4,5 +4,5 @@ export default makeResolver({ name: "n", operation: "query", output: t.string(), - body: ({ caller }) => caller.id, + body: ({ caller }) => caller?.id, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/expected.ts index 3aa4c354f..f0b3a14d7 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/expected.ts @@ -4,5 +4,5 @@ export default createResolver({ name: "n", operation: "query", output: t.string(), - body: ({ caller: currentUser }) => currentUser.id, + body: ({ caller: currentUser }) => currentUser?.id, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/expected.ts index d3498e2ca..3d76c81ce 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/expected.ts @@ -1,5 +1,6 @@ -import { type TailorPrincipal as MyUser } from "@tailor-platform/sdk"; +import { type TailorPrincipal as MyUser, type TailorPrincipal } from "@tailor-platform/sdk"; export type Props = { caller: MyUser; + invoker: (TailorPrincipal | null); }; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/input.ts index d3611f901..c995a2e74 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/input.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/input.ts @@ -1,5 +1,6 @@ -import { type TailorUser as MyUser } from "@tailor-platform/sdk"; +import { type TailorUser as MyUser, type TailorInvoker as MyInvoker } from "@tailor-platform/sdk"; export type Props = { caller: MyUser; + invoker: MyInvoker; }; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/assignment-target/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/assignment-target/input.ts new file mode 100644 index 000000000..f246ece12 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/assignment-target/input.ts @@ -0,0 +1,11 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ user }) => { + user.attributes.role = "ADMIN"; + return user.id; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/expected.ts index 5c1553ae6..e45ae39a2 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/expected.ts @@ -6,7 +6,9 @@ export default createResolver({ input: t.object({ id: t.string() }), output: t.object({ id: t.string() }), body: ({ input, caller }) => { - return { id: caller.id }; + const parsed = t.string().parse({ value: input.id, data: {}, invoker: caller }); + const parsedOther = { parse: (arg: unknown) => arg }.parse({ user: caller }); + return { id: parsed.value ?? caller?.["id"] ?? caller?.id }; }, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/input.ts index 76bb3630e..adb7c5ccb 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/input.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/input.ts @@ -6,7 +6,9 @@ export default createResolver({ input: t.object({ id: t.string() }), output: t.object({ id: t.string() }), body: ({ input, user }) => { - return { id: user.id }; + const parsed = t.string().parse({ value: input.id, data: {}, user }); + const parsedOther = { parse: (arg: unknown) => arg }.parse({ user }); + return { id: parsed.value ?? user["id"] ?? user.id }; }, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/expected.ts index a0c293c7e..4d1855042 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/expected.ts @@ -5,7 +5,7 @@ export default createResolver({ operation: "query", output: t.string(), body: ({ caller }) => { - const userId = caller.id; + const userId = caller?.id; return userId; }, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/expected.ts new file mode 100644 index 000000000..d5fb407d1 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/expected.ts @@ -0,0 +1,13 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const caller = { id: "outer-caller" }; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ caller: user }) => { + const parsed = t.string().parse({ value: "hello", data: {}, invoker: user }); + return user?.id ?? caller.id ?? parsed.value; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/input.ts new file mode 100644 index 000000000..278d0d9ad --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/input.ts @@ -0,0 +1,13 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const caller = { id: "outer-caller" }; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ user }) => { + const parsed = t.string().parse({ value: "hello", data: {}, user }); + return user.id ?? caller.id ?? parsed.value; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/expected.ts index 997a81ed6..ecec00dba 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/expected.ts @@ -5,7 +5,7 @@ export default createResolver({ operation: "query", output: t.object({ id: t.string(), type: t.string() }), body: (ctx) => ({ - id: ctx.caller.id, - type: ctx.caller.type, + id: ctx.caller?.id, + type: ctx.caller?.type, }), }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/expected.ts index 00575ecc3..fc8e2050f 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/expected.ts @@ -6,6 +6,6 @@ export default createResolver({ output: t.string(), body: (ctx) => { const { caller: user } = ctx; - return user.id; + return user?.id; }, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/expected.ts index 5f726a55a..3dc125322 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/expected.ts @@ -6,5 +6,5 @@ export default createResolver({ name: "n", operation: "query", output: t.string(), - body: ({ caller = fallback }) => caller.id, + body: ({ caller = fallback }) => caller?.id, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/expected.ts new file mode 100644 index 000000000..e49dc3660 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/expected.ts @@ -0,0 +1,38 @@ +import * as sdk from "@tailor-platform/sdk"; + +type User = sdk.TailorPrincipal; +type Invokers = (sdk.TailorPrincipal | null)[]; + +const role = sdk.db.string().hooks({ + create: ({ invoker }: { invoker: sdk.TailorPrincipal | null }) => invoker?.id ?? "anonymous", +}); + +export const actorTypeValue: (sdk.TailorPrincipal["type"] | undefined) = "user"; +export const allowedActorTypes: (sdk.TailorPrincipal["type"] | undefined)[] = [ + "user", + "machine_user", +]; + +export function isMachineUser(type: (sdk.TailorPrincipal["type"] | undefined)) { + return type === "machine_user"; +} + +export function actorFields(actor: sdk.TailorPrincipal | null) { + return { + id: actor?.id, + type: actor?.type, + }; +} + +export default sdk.createResolver({ + name: "n", + operation: "query", + output: sdk.t.string(), + body: ({ caller }) => { + const parsed = sdk.t.string().parse({ value: "hello", data: {}, invoker: caller }); + return caller?.id ?? parsed.value; + }, +}); + +export const helper = (u: User) => role.parse({ value: u.id, data: {}, invoker: null }); +export const invokers: Invokers = []; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/input.ts new file mode 100644 index 000000000..884d29e5b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/input.ts @@ -0,0 +1,38 @@ +import * as sdk from "@tailor-platform/sdk"; + +type User = sdk.TailorUser; +type Invokers = sdk.TailorInvoker[]; + +const role = sdk.db.string().hooks({ + create: ({ user }: { user: sdk.TailorUser | null }) => user?.id ?? "anonymous", +}); + +export const actorTypeValue: sdk.TailorActorType = "USER_TYPE_USER"; +export const allowedActorTypes: sdk.TailorActorType[] = [ + "USER_TYPE_USER", + "USER_TYPE_MACHINE_USER", +]; + +export function isMachineUser(type: sdk.TailorActorType) { + return type === "USER_TYPE_MACHINE_USER"; +} + +export function actorFields(actor: sdk.TailorActor | null) { + return { + id: actor?.userId, + type: actor?.userType, + }; +} + +export default sdk.createResolver({ + name: "n", + operation: "query", + output: sdk.t.string(), + body: ({ user }) => { + const parsed = sdk.t.string().parse({ value: "hello", data: {}, user }); + return user.id ?? parsed.value; + }, +}); + +export const helper = (u: User) => role.parse({ value: u.id, data: {}, user: null }); +export const invokers: Invokers = []; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/expected.ts index a645dde3d..28475e634 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/expected.ts @@ -7,7 +7,7 @@ export default createResolver({ operation: "query", output: t.array(t.string()), body: (ctx) => ({ - me: ctx.caller.id, + me: ctx.caller?.id, others: items.map((ctx) => ctx.user.id), }), }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/expected.ts index bd3efce01..a8706f56a 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/expected.ts @@ -6,5 +6,5 @@ export default createResolver({ name: "n", operation: "query", output: t.array(t.string()), - body: ({ caller }) => items.map(({ user }) => user.id).concat(caller.id), + body: ({ caller }) => items.map(({ user }) => user.id).concat(caller?.id), }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/resolver-nested-principal-pattern/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/resolver-nested-principal-pattern/input.ts new file mode 100644 index 000000000..2128998b2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/resolver-nested-principal-pattern/input.ts @@ -0,0 +1,8 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ user: { id } }) => id, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/expected.ts index bf9246993..d987710ec 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/expected.ts @@ -10,6 +10,6 @@ export default createResolver({ const user = { id: "fake" }; return { id: user.id }; } - return { id: caller.id }; + return { id: caller?.id }; }, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadowed-sdk-field/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadowed-sdk-field/input.ts new file mode 100644 index 000000000..11951b8a7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadowed-sdk-field/input.ts @@ -0,0 +1,7 @@ +import { db } from "@tailor-platform/sdk"; + +export function buildField(db: { string(): { hooks(config: unknown): unknown } }) { + return db.string().hooks({ + create: ({ user }: { user: { id: string } }) => user.id, + }); +} diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/expected.ts new file mode 100644 index 000000000..1d23e07aa --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/expected.ts @@ -0,0 +1,150 @@ +import { db, t, type TailorPrincipal, type TailorPrincipal as MyUser } from "@tailor-platform/sdk"; + +const roleCreate = ({ value, invoker }: { value: string; invoker: TailorPrincipal | null }) => + invoker?.attributes.role === "ADMIN" ? value : "user"; + +function hasMachineUser({ invoker }: { invoker: TailorPrincipal | null }) { + return invoker?.type === "machine_user"; +} + +const hasInvoker = (ctx: { invoker: TailorPrincipal | null }) => ctx.invoker?.id !== ""; + +type HookArgs = { + value: string; + invoker: TailorPrincipal | null; +}; + +interface ValidatorArgs { + invoker: TailorPrincipal | null; +} + +const namedTypeHook = ({ invoker }: HookArgs) => invoker?.id ?? "anonymous"; +const namedTypeValidator = ({ invoker }: ValidatorArgs) => invoker?.id !== ""; + +type StrictHookArgs = { + value: string; + invoker: TailorPrincipal | null; +}; + +const strictHook = ({ invoker }: { invoker: TailorPrincipal | null }) => { + const { id } = invoker ?? {}; + return id; +}; + +const namedStrictHook = ({ invoker }: StrictHookArgs) => { + const { id } = invoker ?? {}; + return id; +}; + +const aliasedStrictHook = ({ invoker }: { invoker: MyUser | null }) => invoker?.id; + +const sharedHooks = { + create: ({ invoker }: { invoker: TailorPrincipal | null }) => invoker?.id ?? "anonymous", + update: namedTypeHook, +}; + +const role = db + .string() + .hooks({ + create: roleCreate, + update: (ctx: { invoker: TailorPrincipal | null }) => ctx.invoker?.id ?? "anonymous", + delete({ user }) { + return user?.id ?? "anonymous"; + }, + }) + .validate([ + [hasMachineUser, "Machine user required"], + ctx => ctx.invoker?.id !== "", + ]) + .validate(hasInvoker) + .validate(namedTypeValidator); + +const localHookedRole = db.string().hooks(sharedHooks); +const strictHookedRole = db + .string() + .hooks({ create: strictHook, update: namedStrictHook }) + .hooks({ create: aliasedStrictHook }); + +const invoker = { id: "outer-invoker" }; + +const directHookedRole = db.string().hooks({ + create: ({ invoker }) => { + const parsed = t.string().parse({ value: "hello", data: {}, invoker }); + const parsedOther = { parse: (arg: unknown) => arg }.parse({ user: invoker }); + const { id } = invoker ?? {}; + return parsed.value ?? invoker?.["id"] ?? id; + }, + update: (ctx) => { + const { invoker: user } = ctx; + const { invoker: currentUser } = ctx; + return user?.id ?? currentUser?.id; + }, +}); + +const externalInvokerHookedRole = db.string().hooks({ + create: ({ invoker: user }) => { + const parsed = t.string().parse({ value: "hello", data: {}, invoker: user }); + return user?.id ?? invoker.id ?? parsed.value; + }, +}); + +const reviewer = t.string(); +const zodLike = { parse: (arg: unknown) => arg }; + +export const user = db + .table("User", { + role, + note: db.string(), + }) + .hooks({ + note: { + create: ({ invoker: currentUser }) => { + const audit = [{ user: { id: "data-user" } }].map(({ user }) => user.id); + return currentUser?.id ?? audit[0] ?? "anonymous"; + }, + update({ invoker: user }) { + const invoker = user?.id ?? "anonymous"; + return invoker; + }, + }, + }) + .validate({ + note: (ctx) => ctx.invoker?.type !== "machine_user", + fallback: ({ invoker: user = null }) => { + const labels = ["anonymous"].map((invoker) => invoker); + return user?.id !== labels[0]; + }, + typed: ({ invoker }: { invoker: TailorPrincipal | null }) => invoker?.id !== "", + }); + +export const audit = db + .table("Audit", { + create: db.string(), + update: db.string(), + }) + .hooks({ + create: { + create: ({ invoker }: { invoker: TailorPrincipal | null }) => invoker?.id ?? "anonymous", + update: (ctx: { invoker: TailorPrincipal | null }) => ctx.invoker?.id ?? "anonymous", + }, + }); + +export const parsed = t.string().parse({ + value: "hello", + data: {}, + invoker: null, +}); + +export const parsedLocal = reviewer.parse({ + value: "hello", + data: {}, + invoker: null, +}); + +export const parsedOther = zodLike.parse({ + user: null, +}); + +export function parseWithShadow(reviewer: { parse(arg: unknown): unknown }, user: unknown) { + return reviewer.parse({ user }); +} diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/input.ts new file mode 100644 index 000000000..5609bd0b4 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/input.ts @@ -0,0 +1,151 @@ +import { db, t, type TailorUser, type TailorUser as MyUser } from "@tailor-platform/sdk"; +import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; + +const roleCreate = ({ value, user }: { value: string; user: TailorUser | null }) => + user?.attributes.role === "ADMIN" ? value : "user"; + +function hasMachineUser({ user }: { user: TailorUser | null }) { + return user?.type === "machine_user"; +} + +const hasInvoker = (ctx: { user: TailorUser | null }) => ctx.user?.id !== ""; + +type HookArgs = { + value: string; + user: TailorUser | null; +}; + +interface ValidatorArgs { + user: TailorUser | null; +} + +const namedTypeHook = ({ user }: HookArgs) => user?.id ?? "anonymous"; +const namedTypeValidator = ({ user }: ValidatorArgs) => user?.id !== ""; + +type StrictHookArgs = { + value: string; + user: TailorUser; +}; + +const strictHook = ({ user }: { user: TailorUser }) => { + const { id } = user; + return id; +}; + +const namedStrictHook = ({ user }: StrictHookArgs) => { + const { id } = user; + return id; +}; + +const aliasedStrictHook = ({ user }: { user: MyUser }) => user.id; + +const sharedHooks = { + create: ({ user }: { user: TailorUser | null }) => user?.id ?? "anonymous", + update: namedTypeHook, +}; + +const role = db + .string() + .hooks({ + create: roleCreate, + update: (ctx: { user: TailorUser | null }) => ctx.user?.id ?? "anonymous", + delete({ user }) { + return user?.id ?? "anonymous"; + }, + }) + .validate([ + [hasMachineUser, "Machine user required"], + ctx => ctx.user?.id !== "", + ]) + .validate(hasInvoker) + .validate(namedTypeValidator); + +const localHookedRole = db.string().hooks(sharedHooks); +const strictHookedRole = db + .string() + .hooks({ create: strictHook, update: namedStrictHook }) + .hooks({ create: aliasedStrictHook }); + +const invoker = { id: "outer-invoker" }; + +const directHookedRole = db.string().hooks({ + create: ({ user }) => { + const parsed = t.string().parse({ value: "hello", data: {}, user }); + const parsedOther = { parse: (arg: unknown) => arg }.parse({ user }); + const { id } = user; + return parsed.value ?? user["id"] ?? id; + }, + update: (ctx) => { + const { user } = ctx; + const { user: currentUser } = ctx; + return user.id ?? currentUser.id; + }, +}); + +const externalInvokerHookedRole = db.string().hooks({ + create: ({ user }) => { + const parsed = t.string().parse({ value: "hello", data: {}, user }); + return user.id ?? invoker.id ?? parsed.value; + }, +}); + +const reviewer = t.string(); +const zodLike = { parse: (arg: unknown) => arg }; + +export const user = db + .table("User", { + role, + note: db.string(), + }) + .hooks({ + note: { + create: ({ user: currentUser }) => { + const audit = [{ user: { id: "data-user" } }].map(({ user }) => user.id); + return currentUser?.id ?? audit[0] ?? "anonymous"; + }, + update({ user }) { + const invoker = user?.id ?? "anonymous"; + return invoker; + }, + }, + }) + .validate({ + note: (ctx) => ctx.user?.type !== "machine_user", + fallback: ({ user = unauthenticatedTailorUser }) => { + const labels = ["anonymous"].map((invoker) => invoker); + return user?.id !== labels[0]; + }, + typed: ({ user }: { user: TailorUser | null }) => user?.id !== "", + }); + +export const audit = db + .table("Audit", { + create: db.string(), + update: db.string(), + }) + .hooks({ + create: { + create: ({ user }: { user: TailorUser | null }) => user?.id ?? "anonymous", + update: (ctx: { user: TailorUser | null }) => ctx.user?.id ?? "anonymous", + }, + }); + +export const parsed = t.string().parse({ + value: "hello", + data: {}, + user: unauthenticatedTailorUser, +}); + +export const parsedLocal = reviewer.parse({ + value: "hello", + data: {}, + user: unauthenticatedTailorUser, +}); + +export const parsedOther = zodLike.parse({ + user: unauthenticatedTailorUser, +}); + +export function parseWithShadow(reviewer: { parse(arg: unknown): unknown }, user: unknown) { + return reviewer.parse({ user }); +} diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-nested-principal-pattern/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-nested-principal-pattern/input.ts new file mode 100644 index 000000000..00363a5ac --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-nested-principal-pattern/input.ts @@ -0,0 +1,5 @@ +import { db } from "@tailor-platform/sdk"; + +export const role = db.string().hooks({ + create: ({ user: { id } }) => id, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/expected.ts index ee7750963..ab3398b96 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/expected.ts @@ -3,5 +3,30 @@ import type { TailorPrincipal } from "@tailor-platform/sdk"; export type Props = { user: TailorPrincipal; actor: TailorPrincipal; - invoker: TailorPrincipal; + invoker: (TailorPrincipal | null); + nullableInvoker: (TailorPrincipal | null) | null; + invokers: (TailorPrincipal | null)[]; + actorType: (TailorPrincipal["type"] | undefined); }; + +export function actorFields(actor: TailorPrincipal | null) { + return { + id: actor?.id, + type: actor?.type, + }; +} + +export const actorTypeValue: (TailorPrincipal["type"] | undefined) = "user"; +export const actorTypeMissing: (TailorPrincipal["type"] | undefined) = undefined; +export const allowedActorTypes: (TailorPrincipal["type"] | undefined)[] = [ + "user", + "machine_user", +]; +export const actorTypeConfig: { primary: (TailorPrincipal["type"] | undefined); fallback?: (TailorPrincipal["type"] | undefined) } = { + primary: "user", + fallback: undefined, +}; + +export function isUserType(type: (TailorPrincipal["type"] | undefined)) { + return type === "user"; +} diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/input.ts index d42dfc898..e60423d98 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/input.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/input.ts @@ -1,7 +1,32 @@ -import type { TailorUser, TailorActor, TailorInvoker } from "@tailor-platform/sdk"; +import type { TailorUser, TailorActor, TailorInvoker, TailorActorType } from "@tailor-platform/sdk"; export type Props = { user: TailorUser; actor: TailorActor; invoker: TailorInvoker; + nullableInvoker: TailorInvoker | null; + invokers: TailorInvoker[]; + actorType: TailorActorType; }; + +export function actorFields(actor: TailorActor | null) { + return { + id: actor?.userId, + type: actor?.userType, + }; +} + +export const actorTypeValue: TailorActorType = "USER_TYPE_USER"; +export const actorTypeMissing: TailorActorType = "USER_TYPE_UNSPECIFIED"; +export const allowedActorTypes: TailorActorType[] = [ + "USER_TYPE_USER", + "USER_TYPE_MACHINE_USER", +]; +export const actorTypeConfig: { primary: TailorActorType; fallback?: TailorActorType } = { + primary: "USER_TYPE_USER", + fallback: "USER_TYPE_UNSPECIFIED", +}; + +export function isUserType(type: TailorActorType) { + return type === "USER_TYPE_USER"; +} diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/codemod.yaml b/packages/sdk-codemod/codemods/v2/rename-bin/codemod.yaml new file mode 100644 index 000000000..3c7789c8c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/rename-bin" +version: "1.0.0" +description: "Rename the CLI binary from tailor-sdk to tailor in scripts, source files, CI workflows, and documentation" +engine: jssg +language: text +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/rename-bin/scripts/transform.ts new file mode 100644 index 000000000..c2d93be3a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/scripts/transform.ts @@ -0,0 +1,1633 @@ +import { parse, Lang } from "@ast-grep/napi"; +import * as path from "pathe"; +import type { SgNode } from "@ast-grep/napi"; + +const SOURCE_ARG_VALUE = `(?:[^\\s'"\`;|&]+|'[^']*'|"(?:(?:\\\\.)|[^"\\\\])*")`; +const RUNNER_OPTION_VALUE_FLAG_LIST = [ + "--registry", + "--cache", + "--userconfig", + "--prefix", + "--filter", + "-F", + "--dir", + "-C", + "--cwd", +] as const; +const RUNNER_OPTION_VALUE_FLAG_PATTERN = `(?:${RUNNER_OPTION_VALUE_FLAG_LIST.join("|")})`; +const PACKAGE_RUNNER_BOOLEAN_OPTION = `(?!(?:${RUNNER_OPTION_VALUE_FLAG_PATTERN})(?:=|\\s|$))(?:-\\w+|--\\w[\\w-]*)(?:=${SOURCE_ARG_VALUE})?`; +const PACKAGE_RUNNER_OPTION = `(?:${RUNNER_OPTION_VALUE_FLAG_PATTERN}(?:=${SOURCE_ARG_VALUE}|\\s+${SOURCE_ARG_VALUE})|${PACKAGE_RUNNER_BOOLEAN_OPTION})`; +const PACKAGE_RUNNER_COMMAND = `(?:npx|bunx|(?:pnpm|yarn)(?:\\s+${PACKAGE_RUNNER_OPTION})*\\s+dlx)`; + +// Package-runner forms (`npx`, `pnpm dlx`, `yarn dlx`, `bunx`) resolve npm package +// names, so `tailor-sdk@...` must become `@tailor-platform/sdk@...` — rewriting +// to `tailor@...` would download the unrelated CSS Sprites Generator instead. +// Optional flags (e.g. `-y`, `--yes`) between the runner and the package name are +// captured as part of the runner group so the replacement preserves them. +const PKG_RUNNER_RE = new RegExp( + `\\b(${PACKAGE_RUNNER_COMMAND}(?:\\s+(?:-\\w+|--\\w[\\w-]*))*)\\s+tailor-sdk(?![\\w-])(@[^\\s'"\`;|&)]+)?`, + "g", +); + +// Match the `tailor-sdk` binary, optionally with a version pin (`@latest`, +// `@2.0.0`, etc.). Lookbehind excludes `.tailor-sdk` (preceded by `.`) and +// `create-tailor-sdk` (preceded by `-`). Lookahead excludes trailing `-word` +// (e.g. `tailor-sdk-skills`) to avoid partial-match rewrites. +const TAILOR_SDK_RE = /(? = new Set(RUNNER_OPTION_VALUE_FLAG_LIST); + +function renameBinary(value: string): string { + const withRunners = value.replace(PKG_RUNNER_RE, (_, runner: string, version?: string) => + version ? `${runner} @tailor-platform/sdk${version}` : `${runner} @tailor-platform/sdk`, + ); + return withRunners.replace(TAILOR_SDK_RE, (_match, version?: string) => + version ? `@tailor-platform/sdk${version}` : "tailor", + ); +} + +function renamePackageName(value: string): string { + return value.replace(TAILOR_SDK_TOKEN_RE, (_match, version?: string) => + version ? `@tailor-platform/sdk${version}` : "@tailor-platform/sdk", + ); +} + +function renameSourcePackageToken(token: string): string | null { + const value = sourceTokenValue(token); + if (!TAILOR_SDK_TOKEN_RE.test(value)) return null; + return replaceSourceTokenValue(token, renamePackageName(value)); +} + +function renameSourceBinaryToken(token: string): string | null { + const value = sourceTokenValue(token); + if (!TAILOR_SDK_COMMAND_TOKEN_RE.test(value)) return null; + return replaceSourceTokenValue( + token, + value.includes("@") && !/\.(?:cmd|ps1|exe)$/.test(value) + ? renamePackageName(value) + : renameBinary(value), + ); +} + +function isTailorPackageValue(value: string): boolean { + return TAILOR_SDK_TOKEN_RE.test(value) || TAILOR_PLATFORM_SDK_TOKEN_RE.test(value); +} + +function sourcePathBasename(value: string): string { + return value.split(/[\\/]/).at(-1) ?? value; +} + +function isTailorSdkBinaryTokenValue(value: string): boolean { + return TAILOR_SDK_COMMAND_TOKEN_RE.test(sourcePathBasename(value)); +} + +function isTailorCliTokenValue(value: string): boolean { + const basename = sourcePathBasename(value); + return ( + TAILOR_CLI_TOKEN_RE.test(value) || + TAILOR_COMMAND_TOKEN_RE.test(basename) || + TAILOR_SDK_COMMAND_TOKEN_RE.test(basename) + ); +} + +function replaceSourceSpans( + value: string, + replacements: Map, + tokens: Array<{ start: number; end: number }>, +): string { + let updated = value; + for (const [index, replacement] of [...replacements.entries()].toSorted(([a], [b]) => b - a)) { + const token = tokens[index]; + if (token == null) continue; + updated = `${updated.slice(0, token.start)}${replacement}${updated.slice(token.end)}`; + } + return updated; +} + +type ProtectedSourceValue = { placeholder: string; value: string }; + +function sourceValuePlaceholder( + index: number, + source: string, + usedPlaceholders: Set, +): string { + let attempt = 0; + while (true) { + const placeholder = `__TAILOR_SDK_SOURCE_VALUE_${index}_${attempt}__`; + if (!source.includes(placeholder) && !usedPlaceholders.has(placeholder)) { + usedPlaceholders.add(placeholder); + return placeholder; + } + attempt += 1; + } +} + +function protectSourceCliValueReferences(value: string): { + source: string; + protectedValues: ProtectedSourceValue[]; +} { + const protectedValues: ProtectedSourceValue[] = []; + const usedPlaceholders = new Set(); + const source = value.replace( + SOURCE_OPTION_VALUE_REFERENCE_RE, + (match: string, prefix: string, arg: string, offset: number) => { + if (!arg.includes("tailor-sdk")) return match; + const flag = sourceOptionFlag(prefix); + const afterPackageRunner = isAfterPackageRunnerPrefix(value, offset); + if (afterPackageRunner && !isPackageRunnerOptionValue(value, offset, flag)) { + return match; + } + if (!afterPackageRunner && !isAfterTailorCliToken(value, offset)) { + return match; + } + if (isPackageFlag(flag) && NPX_PACKAGE_FLAG_CONTEXT_RE.test(value.slice(0, offset))) { + return match; + } + const placeholder = sourceValuePlaceholder(protectedValues.length, value, usedPlaceholders); + protectedValues.push({ placeholder, value: arg }); + return `${prefix}${placeholder}`; + }, + ); + return { source, protectedValues }; +} + +function restoreSourceCliValueReferences( + value: string, + protectedValues: ProtectedSourceValue[], +): string { + let restored = value; + for (const protectedValue of protectedValues) { + restored = restored.replaceAll(protectedValue.placeholder, () => protectedValue.value); + } + return restored; +} + +function sourceOptionFlag(prefix: string): string { + return prefix.trim().replace(/[=\s].*$/, ""); +} + +function isPackageFlag(value: string): boolean { + return value === "-p" || value === "--package"; +} + +function sourceTokens(value: string): string[] | null { + const tokens = sourceTokenSpans(value); + return tokens == null ? null : tokens.map((token) => token.value); +} + +function sourceTokenSpans(value: string): Array<{ + start: number; + end: number; + text: string; + value: string; +}> | null { + const tokens: string[] = []; + SOURCE_TOKEN_RE.lastIndex = 0; + let lastEnd = 0; + for (const match of value.matchAll(SOURCE_TOKEN_RE)) { + if (value.slice(lastEnd, match.index).trim() !== "") return null; + tokens.push(sourceTokenValue(match[0])); + lastEnd = (match.index ?? 0) + match[0].length; + } + if (value.slice(lastEnd).trim() !== "") return null; + + const spans = [...value.matchAll(new RegExp(SOURCE_CLI_ARG_VALUE, "g"))].map((match) => ({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + text: match[0], + value: sourceTokenValue(match[0]), + })); + return mergeSourceEqualsQuotedTokenSpans(spans); +} + +function mergeSourceEqualsQuotedTokenSpans( + spans: Array<{ start: number; end: number; text: string; value: string }>, +): Array<{ start: number; end: number; text: string; value: string }> { + const merged: Array<{ start: number; end: number; text: string; value: string }> = []; + for (const span of spans) { + const previous = merged.at(-1); + if (previous != null && previous.end === span.start && previous.text.endsWith("=")) { + previous.end = span.end; + previous.text += span.text; + previous.value += span.value; + continue; + } + merged.push({ ...span }); + } + return merged; +} + +function sourceTokenValue(token: string): string { + if (token.startsWith('\\"') && token.endsWith('\\"')) return token.slice(2, -2); + if (token.startsWith("\\'") && token.endsWith("\\'")) return token.slice(2, -2); + if (token.startsWith('"') && token.endsWith('"')) return token.slice(1, -1); + if (token.startsWith("'") && token.endsWith("'")) return token.slice(1, -1); + return token; +} + +function replaceSourceTokenValue(token: string, replacement: string): string { + if (token.startsWith('\\"') && token.endsWith('\\"')) return `\\"${replacement}\\"`; + if (token.startsWith("\\'") && token.endsWith("\\'")) return `\\'${replacement}\\'`; + if (token.startsWith('"') && token.endsWith('"')) return `"${replacement}"`; + if (token.startsWith("'") && token.endsWith("'")) return `'${replacement}'`; + return replacement; +} + +function activeQuoteStart(source: string, start: number, offset: number): number | null { + let quote: { delimiter: string; escaped: boolean; start: number } | null = null; + for (let index = start; index < offset; index += 1) { + const char = source[index]; + if (quote != null) { + if (quote.escaped) { + if (char === "\\") { + let runEnd = index + 1; + while (source[runEnd] === "\\") runEnd += 1; + if (runEnd === index + 1 && source[runEnd] === quote.delimiter) { + quote = null; + index = runEnd; + } else { + index = runEnd - 1; + } + } + } else if (char === "\\") { + index += 1; + } else if (char === quote.delimiter) { + quote = null; + } + continue; + } + if (char === "\\" && (source[index + 1] === '"' || source[index + 1] === "'")) { + quote = { delimiter: source[index + 1]!, escaped: true, start: index }; + index += 1; + } else if (char === '"' || char === "'") { + quote = { delimiter: char, escaped: false, start: index }; + } + } + return quote?.start ?? null; +} + +function skipsRunnerOptionValue(token: string, executable?: string): boolean { + const flag = token.split("=", 1)[0]!; + return ( + (RUNNER_OPTION_VALUE_FLAGS.has(flag) || + (executable === "npm" && NPM_OPTION_VALUE_FLAGS.has(flag))) && + !token.includes("=") + ); +} + +function isPotentialValueFlag(value: string): boolean { + const flag = value.split("=", 1)[0]!; + return /^--[\w-]+$/.test(flag) || /^-\w$/.test(flag); +} + +function packageRunnerPackageStartTokenIndex(tokens: readonly string[]): number | null { + const executable = tokens[0]; + if (executable === "npx" || executable === "bunx") { + return 1; + } else if (executable === "npm") { + let index = 1; + for (; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token === "exec") { + return index + 1; + } + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, executable)) index += 1; + continue; + } + return null; + } + } else if (executable === "pnpm" || executable === "yarn") { + let index = 1; + for (; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token === "dlx") { + return index + 1; + } + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, executable)) index += 1; + continue; + } + return null; + } + } + return null; +} + +function renameSourcePackageRunnerTokens(value: string): string { + return transformSourceCommandSegments(value, renameSourcePackageRunnerTokenSegment); +} + +function transformSourceCommandSegments( + value: string, + transform: (segment: string) => string, +): string { + let result = ""; + let segmentStart = 0; + let quote: string | null = null; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]!; + if (quote != null) { + if (char === "\\") { + index += 1; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === "\\" && (value[index + 1] === '"' || value[index + 1] === "'")) { + index += 1; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === ";" || char === "&" || char === "|") { + result += transform(value.slice(segmentStart, index)) + char; + segmentStart = index + 1; + } + } + return result + transform(value.slice(segmentStart)); +} + +function renameSourcePackageRunnerTokenSegment(value: string): string { + const spans = sourceTokenSpans(value); + if (spans == null) return value; + const tokens = spans.map((span) => span.value); + const start = packageRunnerPackageStartTokenIndex(tokens); + if (start == null) return value; + + const replacements = new Map(); + let hasPackageFlag = false; + let hasTailorPackageFlag = false; + + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (SOURCE_PACKAGE_FLAG_RE.test(token)) { + hasPackageFlag = true; + if (token.includes("=")) { + const [flag, rawValue = ""] = spans[index]!.text.split(/=(.*)/s, 2); + const packageReplacement = renameSourcePackageToken(rawValue); + if (packageReplacement != null) { + replacements.set(index, `${flag}=${packageReplacement}`); + hasTailorPackageFlag = true; + } else if (isTailorPackageValue(sourceTokenValue(rawValue))) { + hasTailorPackageFlag = true; + } + } else { + const valueIndex = index + 1; + const value = spans[valueIndex]; + if (value != null) { + const packageReplacement = renameSourcePackageToken(value.text); + if (packageReplacement != null) { + replacements.set(valueIndex, packageReplacement); + hasTailorPackageFlag = true; + } else if (isTailorPackageValue(value.value)) { + hasTailorPackageFlag = true; + } + } + index += 1; + } + continue; + } + + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, tokens[0])) index += 1; + continue; + } + + if (!hasPackageFlag) { + const packageReplacement = renameSourcePackageToken(spans[index]!.text); + if (packageReplacement != null) replacements.set(index, packageReplacement); + break; + } + + if (hasTailorPackageFlag) { + const binaryReplacement = renameSourceBinaryToken(spans[index]!.text); + if (binaryReplacement != null) replacements.set(index, binaryReplacement); + } + break; + } + + return replacements.size === 0 ? value : replaceSourceSpans(value, replacements, spans); +} + +function hasPositionalPackageBeforeSourcePackageFlag( + tokens: readonly string[], + start: number, +): boolean { + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (SOURCE_PACKAGE_FLAG_RE.test(token)) { + if (!token.includes("=")) index += 1; + continue; + } + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, tokens[0])) index += 1; + continue; + } + return true; + } + return false; +} + +function firstRunnerPackageToken(tokens: string[]): string | null { + const start = packageRunnerPackageStartTokenIndex(tokens); + if (start == null) return null; + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (SOURCE_PACKAGE_FLAG_RE.test(token)) { + return token.includes("=") + ? sourceTokenValue(token.slice(token.indexOf("=") + 1)) + : (tokens[index + 1] ?? null); + } + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, tokens[0])) index += 1; + continue; + } + return token; + } + return null; +} + +function isPackageRunnerOptionValue(source: string, offset: number, flag: string): boolean { + if (!RUNNER_OPTION_VALUE_FLAGS.has(flag)) return false; + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const tokens = sourceTokens(source.slice(segmentStart + 1, offset).trim()); + const executable = tokens?.[0]; + return ( + executable === "npx" || executable === "bunx" || executable === "pnpm" || executable === "yarn" + ); +} + +function isAfterPackageRunnerPrefix(source: string, offset: number): boolean { + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const tokens = sourceTokens(source.slice(segmentStart + 1, offset).trim()); + const executable = tokens?.[0]; + if (executable === "npx" || executable === "bunx") return true; + return (executable === "pnpm" || executable === "yarn") && tokens?.includes("dlx") === true; +} + +function isPackageFlagValueInPackageRunner(source: string, offset: number): boolean { + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const tokens = sourceTokens(source.slice(segmentStart + 1, offset).trim()); + if (tokens == null) return false; + const start = packageRunnerPackageStartTokenIndex(tokens); + return start != null && !hasPositionalPackageBeforeSourcePackageFlag(tokens, start); +} + +function sourcePackageFlagsAllowBinaryRewrite(source: string): boolean { + const tokens = sourceTokens(source.trim()); + if (tokens == null) return false; + const start = packageRunnerPackageStartTokenIndex(tokens); + if (start == null) return false; + + let hasPackageFlag = false; + let hasTailorPackageFlag = false; + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (SOURCE_PACKAGE_FLAG_RE.test(token)) { + hasPackageFlag = true; + const rawValue = token.includes("=") + ? token.slice(token.indexOf("=") + 1) + : tokens[index + 1]; + const value = rawValue == null ? null : sourceTokenValue(rawValue); + if (value != null && isTailorPackageValue(value)) { + hasTailorPackageFlag = true; + } + if (!token.includes("=")) index += 1; + continue; + } + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, tokens[0])) index += 1; + continue; + } + return false; + } + return hasPackageFlag && hasTailorPackageFlag; +} + +function isAfterOtherPackageRunner(source: string, offset: number): boolean { + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const segment = source.slice(segmentStart + 1, offset).trim(); + const tokens = sourceTokens(segment); + if (tokens == null) { + const quoteStart = activeQuoteStart(source, segmentStart + 1, offset); + if (quoteStart == null) return false; + const prefixTokens = sourceTokens(source.slice(segmentStart + 1, quoteStart).trim()); + const packageToken = prefixTokens == null ? null : firstRunnerPackageToken(prefixTokens); + return packageToken != null && !TAILOR_SDK_TOKEN_RE.test(packageToken); + } + const packageToken = firstRunnerPackageToken(tokens); + return packageToken != null && !TAILOR_SDK_TOKEN_RE.test(packageToken); +} + +function isAfterTailorCliToken(source: string, offset: number): boolean { + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const segment = source.slice(segmentStart + 1, offset).trim(); + const tokens = sourceTokens(segment); + return tokens != null && tokens.some((token) => isTailorCliTokenValue(token)); +} + +function isAfterTemplatePlaceholder(source: string, offset: number): boolean { + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const segment = source.slice(segmentStart + 1, offset).trim(); + const tokens = sourceTokens(segment); + return tokens != null && tokens.some((token) => SOURCE_TEMPLATE_EXPR_PLACEHOLDER_RE.test(token)); +} + +function isTemplateSubstitutionCliValue(text: string, offset: number): boolean { + const tokens = sourceTokens(text.slice(0, offset).trimEnd()); + if (tokens == null || tokens.length === 0) return false; + const previous = tokens.at(-1)!; + if (!isTailorCliValueFlag(previous)) return false; + return tokens.slice(0, -1).some((token) => isTailorCliTokenValue(token)); +} + +function needsCliRenameMigration(value: string): boolean { + if (!value.includes("tailor-sdk")) return false; + const tokens = sourceTokens(value); + if (tokens == null) return CLI_RENAME_LEGACY_RE.test(value); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (isTailorCliValueToken(tokens, index)) continue; + if (token !== value && token.includes("tailor-sdk") && needsCliRenameMigration(token)) { + return true; + } + if (!isTailorSdkBinaryTokenValue(token)) continue; + if (tokensAfterCliBinaryNeedRename(tokens, index + 1)) return true; + } + return false; +} + +function isTailorCliValueToken(tokens: readonly string[], index: number): boolean { + const token = tokens[index]!; + const flag = token.split("=", 1)[0]!; + if ( + token.includes("=") && + TAILOR_CLI_VALUE_FLAGS.has(flag) && + tokens.slice(0, index).some((value) => isTailorCliTokenValue(value)) + ) { + return true; + } + + const previous = tokens[index - 1]; + return ( + previous != null && + TAILOR_CLI_VALUE_FLAGS.has(previous.split("=", 1)[0]!) && + !previous.includes("=") && + tokens.slice(0, index - 1).some((value) => isTailorCliTokenValue(value)) + ); +} + +function tokensAfterCliBinaryNeedRename(tokens: readonly string[], start: number): boolean { + let commandSeen = false; + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token === "--machineuser" || token.startsWith("--machineuser=")) return true; + if (token.startsWith("-")) { + if (TAILOR_CLI_VALUE_FLAGS.has(token.split("=", 1)[0]!) && !token.includes("=")) { + index += 1; + } + continue; + } + if (!commandSeen) { + if (token === "apply" || token === "crash-report") return true; + commandSeen = true; + } + } + return false; +} + +function renameSourceCommandText(value: string): string { + if (needsCliRenameMigration(value)) return value; + + const protectedValue = protectSourceCliValueReferences(value); + const withQuotedPackageFlagValues = protectedValue.source.replace( + SOURCE_PACKAGE_FLAG_EQUALS_QUOTED_VALUE_RE, + ( + match: string, + prefix: string, + openQuote: string, + version: string | undefined, + closeQuote: string, + offset: number, + source: string, + ) => { + if (!isPackageFlagValueInPackageRunner(source, offset)) return match; + const packageName = version ? `@tailor-platform/sdk${version}` : "@tailor-platform/sdk"; + return `${prefix}${openQuote}${packageName}${closeQuote}`; + }, + ); + const withTokenPackageRunners = renameSourcePackageRunnerTokens(withQuotedPackageFlagValues); + const withPackageFlagValues = withTokenPackageRunners.replace( + SOURCE_PACKAGE_FLAG_VALUE_RE, + ( + match: string, + prefix: string, + version: string | undefined, + offset: number, + source: string, + ) => { + if (!isPackageFlagValueInPackageRunner(source, offset)) return match; + return version ? `${prefix}@tailor-platform/sdk${version}` : `${prefix}@tailor-platform/sdk`; + }, + ); + const withPackageRunners = withPackageFlagValues.replace( + SOURCE_PKG_RUNNER_RE, + (match: string, runner: string, version?: string) => { + if (/\s(?:-p|--package)(?:=|\s|$)/.test(runner)) return match; + return version + ? `${runner} @tailor-platform/sdk${version}` + : `${runner} @tailor-platform/sdk`; + }, + ); + const withPackageFlagBinaries = withPackageRunners.replace( + SOURCE_PACKAGE_FLAG_BINARY_RE, + (match: string, prefix: string, version?: string) => { + if (!/\s(?:-p|--package)(?:=|\s)/.test(prefix)) return match; + if (/(?:^|\s)(?:-p|--package)\s+$/.test(prefix)) return match; + if (!sourcePackageFlagsAllowBinaryRewrite(prefix)) return match; + return version ? `${prefix}@tailor-platform/sdk${version}` : `${prefix}tailor`; + }, + ); + const withCommands = withPackageFlagBinaries.replace( + SOURCE_TAILOR_SDK_RE, + ( + match: string, + shim: string | undefined, + version: string | undefined, + offset: number, + source: string, + ) => { + if (isAfterOtherPackageRunner(source, offset)) return match; + if (isAfterTemplatePlaceholder(source, offset)) return match; + if (shim != null) return `tailor${shim}`; + return version ? `@tailor-platform/sdk${version}` : "tailor"; + }, + ); + const withDynamicCommands = withCommands.replace( + SOURCE_DYNAMIC_TAILOR_SDK_RE, + (_match, prefix: string, shim?: string, version?: string) => + shim != null + ? `${prefix}tailor${shim}` + : version + ? `${prefix}@tailor-platform/sdk${version}` + : `${prefix}tailor`, + ); + const updated = withDynamicCommands.replace( + SOURCE_DYNAMIC_OPTION_TAILOR_SDK_RE, + (_match, prefix: string, shim?: string, version?: string) => + shim != null + ? `${prefix}tailor${shim}` + : version + ? `${prefix}@tailor-platform/sdk${version}` + : `${prefix}tailor`, + ); + return restoreSourceCliValueReferences(updated, protectedValue.protectedValues); +} + +function sourceLang(filePath: string): Lang { + const ext = path.extname(filePath).toLowerCase(); + return ext === ".tsx" || ext === ".jsx" || ext === ".js" ? Lang.Tsx : Lang.TypeScript; +} + +function pushSourceTextEdit( + edits: Array<[number, number, string]>, + source: string, + start: number, + end: number, +): void { + const text = source.slice(start, end); + const replacement = renameSourceCommandText(text); + if (replacement !== text) { + edits.push([start, end, replacement]); + } +} + +function nodeRangeKey(node: SgNode): string { + const range = node.range(); + return `${range.start.index}:${range.end.index}`; +} + +function sourceStringContent(node: SgNode, source: string): string | null { + const kind = node.kind(); + if (kind !== "string" && kind !== "template_string") return null; + if ( + kind === "template_string" && + node.children().some((child: SgNode) => child.kind() === "template_substitution") + ) { + return null; + } + const range = node.range(); + return source.slice(range.start.index + 1, range.end.index - 1); +} + +function sourceStringRawContent(node: SgNode, source: string): string | null { + const kind = node.kind(); + if (kind !== "string" && kind !== "template_string") return null; + const range = node.range(); + return source.slice(range.start.index + 1, range.end.index - 1); +} + +function sourceConstInitializerContent(node: SgNode, source: string): string | null { + const directValue = sourceStringContent(node, source); + if (directValue != null) return directValue; + if ( + node.kind() !== "as_expression" && + node.kind() !== "satisfies_expression" && + node.kind() !== "parenthesized_expression" + ) { + return null; + } + for (const child of node.children()) { + const childValue = sourceConstInitializerContent(child, source); + if (childValue != null) return childValue; + } + return null; +} + +function isConstVariableDeclarator(node: SgNode): boolean { + return ( + node + .parent() + ?.children() + .some((child) => child.kind() === "const") ?? false + ); +} + +function sourceStaticStringContent(node: SgNode, source: string): string | null { + return ( + sourceStringContent(node, source) ?? + (node.kind() === "identifier" ? sourceScopedStringVariableContent(node, source) : null) + ); +} + +function sourceScopedStringVariableContent(identifier: SgNode, source: string): string | null { + const name = identifier.text(); + const before = identifier.range().start.index; + let current = identifier.parent(); + while (current != null) { + if (isSourceScopeNode(current)) { + const value = findSourceStringVariableInScope(current, name, before, source); + if (value != null) return value; + } + current = current.parent(); + } + return null; +} + +function findSourceStringVariableInScope( + scope: SgNode, + name: string, + before: number, + source: string, +): string | null { + let value: string | null = null; + const visit = (node: SgNode): void => { + if (node !== scope && isSourceScopeNode(node)) return; + if (node.kind() === "variable_declarator" && node.range().end.index < before) { + const declarationValue = sourceStringVariableDeclarationValue(node, name, source); + if (declarationValue != null) value = declarationValue; + return; + } + for (const child of node.children()) { + visit(child); + } + }; + visit(scope); + return value; +} + +function sourceStringVariableDeclarationValue( + node: SgNode, + name: string, + source: string, +): string | null { + if (!isConstVariableDeclarator(node)) return null; + const children = node.children(); + const identifier = children.find((child) => child.kind() === "identifier"); + if (identifier?.text() !== name) return null; + const initializer = children.findLast( + (child) => sourceConstInitializerContent(child, source) != null, + ); + return initializer == null ? null : sourceConstInitializerContent(initializer, source); +} + +function isSourceScopeNode(node: SgNode): boolean { + const kind = node.kind(); + return ( + kind === "program" || + kind === "statement_block" || + kind === "function_declaration" || + kind === "arrow_function" || + kind === "method_definition" + ); +} + +function isSyntaxOnlyNode(node: SgNode): boolean { + const kind = node.kind(); + return ( + kind === "[" || + kind === "]" || + kind === "(" || + kind === ")" || + kind === "," || + kind === "comment" + ); +} + +function sourceArrayElements(node: SgNode): SgNode[] { + return node.children().filter((child: SgNode) => !isSyntaxOnlyNode(child)); +} + +function nodeIndex(nodes: SgNode[], node: SgNode): number { + return nodes.findIndex((child: SgNode) => nodeRangeKey(child) === nodeRangeKey(node)); +} + +function callExpressionCalleeText(argumentsNode: SgNode): string | null { + const parentRange = nodeRangeKey(argumentsNode); + const call = argumentsNode.parent(); + if (call?.kind() !== "call_expression") return null; + const callee = call.children().find((child: SgNode) => nodeRangeKey(child) !== parentRange); + return callee?.text() ?? null; +} + +function firstNonOptionIndex( + elements: SgNode[], + start: number, + source: string, + executable: string, +): number | null { + for (let index = start; index < elements.length; index += 1) { + const value = sourceStringContent(elements[index]!, source); + if (value == null) return null; + if (!value.startsWith("-")) return index; + if (skipsRunnerOptionValue(value, executable)) { + index += 1; + } + } + return null; +} + +function isTailorCliValueFlag(value: string): boolean { + return TAILOR_CLI_VALUE_FLAGS.has(value.split("=", 1)[0]!) || isPotentialValueFlag(value); +} + +function packageRunnerPackageStartIndex( + executable: string, + elements: SgNode[], + source: string, +): number | null { + if (SOURCE_PACKAGE_RUNNERS.has(executable)) return 0; + if (SOURCE_NPM_EXEC_PACKAGE_RUNNERS.has(executable)) { + const execIndex = firstNonOptionIndex(elements, 0, source, executable); + if (execIndex == null || sourceStringContent(elements[execIndex]!, source) !== "exec") { + return null; + } + return execIndex + 1; + } + if (!SOURCE_DLX_PACKAGE_RUNNERS.has(executable)) return null; + const dlxIndex = firstNonOptionIndex(elements, 0, source, executable); + if (dlxIndex == null || sourceStringContent(elements[dlxIndex]!, source) !== "dlx") { + return null; + } + return dlxIndex + 1; +} + +function hasPackageFlagBeforeArrayPackage( + elements: SgNode[], + index: number, + source: string, + start: number, + executable: string, +): boolean { + for (let currentIndex = start; currentIndex < index; currentIndex += 1) { + const value = sourceStringContent(elements[currentIndex]!, source); + if (value == null) return false; + if (SOURCE_PACKAGE_FLAG_RE.test(value)) return true; + if (value.startsWith("-")) { + if (skipsRunnerOptionValue(value, executable)) currentIndex += 1; + continue; + } + return false; + } + return false; +} + +function isKnownTailorPackageValue(value: string | null): boolean { + return ( + value != null && (TAILOR_SDK_TOKEN_RE.test(value) || TAILOR_PLATFORM_SDK_TOKEN_RE.test(value)) + ); +} + +function hasTailorPackageFlagBeforeArrayCommand( + elements: SgNode[], + index: number, + source: string, + start: number, + executable: string, +): boolean { + for (let currentIndex = start; currentIndex < index; currentIndex += 1) { + const value = sourceStringContent(elements[currentIndex]!, source); + if (value == null) return false; + if (SOURCE_PACKAGE_FLAG_RE.test(value)) { + if (value.includes("=")) { + if (isKnownTailorPackageValue(value.slice(value.indexOf("=") + 1))) return true; + } else { + const nextValue = sourceStringContent(elements[currentIndex + 1]!, source); + if (isKnownTailorPackageValue(nextValue)) return true; + currentIndex += 1; + } + continue; + } + if (value.startsWith("-")) { + if (skipsRunnerOptionValue(value, executable)) currentIndex += 1; + continue; + } + return false; + } + return false; +} + +function isSplitPackageFlagValue( + elements: SgNode[], + index: number, + source: string, + start: number, + executable: string, +): boolean { + const previous = elements[index - 1]; + if (previous == null) return false; + const previousValue = sourceStringContent(previous, source); + return ( + (previousValue === "-p" || previousValue === "--package") && + hasPackageFlagBeforeArrayPackage(elements, index, source, start, executable) + ); +} + +function sourcePackageFlagReplacement( + node: SgNode, + source: string, +): { text: string; replacement: string } | null { + const text = sourceStringContent(node, source); + if (text == null) return null; + const match = /^(?-p=|--package=)tailor-sdk(?@[^\s'"`;|&)]+)?$/.exec(text); + if (match?.groups == null) return null; + const parent = node.parent(); + if (parent?.kind() !== "array" || !isPackageRunnerArrayArgument(node, source)) return null; + const argumentsNode = parent.parent(); + if (argumentsNode?.kind() !== "arguments") return null; + const callArgs = sourceArrayElements(argumentsNode); + const executableNode = callArgs[0]; + if (executableNode == null) return null; + const executable = sourceStringContent(executableNode, source); + if (executable == null) return null; + const elements = sourceArrayElements(parent); + const index = nodeIndex(elements, node); + const start = packageRunnerPackageStartIndex(executable, elements, source); + if (index < 0 || start == null) return null; + if (!hasPackageFlagBeforeArrayPackage(elements, index + 1, source, start, executable)) { + return null; + } + return { + text, + replacement: `${match.groups.prefix}${renamePackageName( + `tailor-sdk${match.groups.version ?? ""}`, + )}`, + }; +} + +function firstTailorPackageIndex( + elements: SgNode[], + start: number, + source: string, + executable: string, +): number | null { + for (let index = start; index < elements.length; index += 1) { + const value = sourceStringContent(elements[index]!, source); + if (value == null) return null; + if (value.startsWith("-")) { + if (skipsRunnerOptionValue(value, executable)) index += 1; + continue; + } + if (TAILOR_SDK_TOKEN_RE.test(value)) { + return index; + } + return null; + } + return null; +} + +function isPackageRunnerArrayArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() !== "array") return false; + + const argumentsNode = parent.parent(); + if (argumentsNode?.kind() !== "arguments") return false; + const callee = callExpressionCalleeText(argumentsNode); + if (!CLI_ARGUMENT_CALLEE_RE.test(callee ?? "")) return false; + + const callArgs = sourceArrayElements(argumentsNode); + const executableNode = callArgs[0]; + if (executableNode == null) return false; + const executable = sourceStringContent(executableNode, source); + if (executable == null) return false; + + if (executable === "bunx" || executable === "npx") return true; + if (executable === "npm") { + const elements = sourceArrayElements(parent); + const execIndex = firstNonOptionIndex(elements, 0, source, executable); + return execIndex != null && sourceStringContent(elements[execIndex]!, source) === "exec"; + } + if (executable !== "pnpm" && executable !== "yarn") return false; + + const elements = sourceArrayElements(parent); + const dlxIndex = firstNonOptionIndex(elements, 0, source, executable); + return dlxIndex != null && sourceStringContent(elements[dlxIndex]!, source) === "dlx"; +} + +function isPackageRunnerPackageArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() !== "array" || !isPackageRunnerArrayArgument(node, source)) return false; + + const argumentsNode = parent.parent(); + if (argumentsNode?.kind() !== "arguments") return false; + const callArgs = sourceArrayElements(argumentsNode); + const executableNode = callArgs[0]; + if (executableNode == null) return false; + const executable = sourceStringContent(executableNode, source); + if (executable == null) return false; + + const elements = sourceArrayElements(parent); + const index = nodeIndex(elements, node); + if (index < 0) return false; + const start = packageRunnerPackageStartIndex(executable, elements, source); + if (start == null) return false; + + if (hasPackageFlagBeforeArrayPackage(elements, index, source, start, executable)) { + return isSplitPackageFlagValue(elements, index, source, start, executable); + } + + return ( + firstTailorPackageIndex(elements, start, source, executable) === index && + !arrayHasCliRenameLegacyArgs(elements, index + 1, source) + ); +} + +function isPackageRunnerCommandBinaryArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() !== "array" || !isPackageRunnerArrayArgument(node, source)) return false; + + const elements = sourceArrayElements(parent); + const index = nodeIndex(elements, node); + if (index < 0) return false; + + const argumentsNode = parent.parent(); + if (argumentsNode?.kind() !== "arguments") return false; + const callArgs = sourceArrayElements(argumentsNode); + const executableNode = callArgs[0]; + if (executableNode == null) return false; + const executable = sourceStringContent(executableNode, source); + if (executable == null) return false; + const start = packageRunnerPackageStartIndex(executable, elements, source); + if ( + start == null || + !hasTailorPackageFlagBeforeArrayCommand(elements, index, source, start, executable) + ) { + return false; + } + if (arrayHasCliRenameLegacyArgs(elements, index + 1, source)) { + return false; + } + const commandIndex = packageRunnerCommandIndex(elements, start, source, executable); + if (commandIndex !== index) return false; + + const text = sourceStringContent(node, source); + return ( + text != null && + TAILOR_SDK_TOKEN_RE.test(text) && + !isSplitPackageFlagValue(elements, index, source, start, executable) + ); +} + +function packageRunnerCommandIndex( + elements: SgNode[], + start: number, + source: string, + executable: string, +): number | null { + for (let index = start; index < elements.length; index += 1) { + const value = sourceStringContent(elements[index]!, source); + if (value == null) return null; + if (SOURCE_PACKAGE_FLAG_RE.test(value)) { + if (!value.includes("=")) index += 1; + continue; + } + if (value.startsWith("-")) { + if (skipsRunnerOptionValue(value, executable)) index += 1; + continue; + } + return index; + } + return null; +} + +function isPackageManagerExecBinaryArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() !== "array") return false; + + const argumentsNode = parent.parent(); + if (argumentsNode?.kind() !== "arguments") return false; + const callee = callExpressionCalleeText(argumentsNode); + if (!CLI_ARGUMENT_CALLEE_RE.test(callee ?? "")) return false; + + const callArgs = sourceArrayElements(argumentsNode); + const executableNode = callArgs[0]; + if (executableNode == null) return false; + const executable = sourceStringContent(executableNode, source); + if (executable == null || !SOURCE_EXEC_PACKAGE_MANAGERS.has(executable)) return false; + + const elements = sourceArrayElements(parent); + const index = nodeIndex(elements, node); + if (index < 0) return false; + + const execIndex = firstNonOptionIndex(elements, 0, source, executable); + if (execIndex == null || sourceStringContent(elements[execIndex]!, source) !== "exec") { + return false; + } + return ( + firstNonOptionIndex(elements, execIndex + 1, source, executable) === index && + !arrayHasCliRenameLegacyArgs(elements, index + 1, source) + ); +} + +function isCliBinaryArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() === "array") { + if (isPackageRunnerCommandBinaryArgument(node, source)) return true; + if (isPackageRunnerArrayArgument(node, source)) return false; + return isPackageManagerExecBinaryArgument(node, source); + } + + if (parent?.kind() !== "arguments") return false; + if (!CLI_ARGUMENT_CALLEE_RE.test(callExpressionCalleeText(parent) ?? "")) return false; + const args = sourceArrayElements(parent); + if (args[0] == null || nodeRangeKey(args[0]) !== nodeRangeKey(node)) return false; + const argv = args[1]; + if (argv == null) return true; + if (argv.kind() === "object") return true; + return ( + argv.kind() === "array" && !arrayHasCliRenameLegacyArgs(sourceArrayElements(argv), 0, source) + ); +} + +function arrayHasCliRenameLegacyArgs(elements: SgNode[], start: number, source: string): boolean { + let commandSeen = false; + for (let index = start; index < elements.length; index += 1) { + const value = + sourceStaticStringContent(elements[index]!, source) ?? + sourceStringRawContent(elements[index]!, source); + if (value == null) continue; + if (value === "--machineuser" || value.startsWith("--machineuser=")) return true; + if ( + TAILOR_CLI_VALUE_FLAGS.has(value.split("=", 1)[0]!) || + (commandSeen && TAILOR_CLI_COMMAND_VALUE_FLAGS.has(value.split("=", 1)[0]!)) + ) { + if (!value.includes("=")) index += 1; + continue; + } + if (value.startsWith("-")) continue; + if (!commandSeen) { + if (CLI_RENAME_LEGACY_RE.test(value)) return true; + if (value === "apply" || value === "crash-report") return true; + commandSeen = true; + } + } + return false; +} + +function isTailorCliArgumentArray(arrayNode: SgNode, index: number, source: string): boolean { + const argumentsNode = arrayNode.parent(); + if (argumentsNode?.kind() === "arguments") { + const callArgs = sourceArrayElements(argumentsNode); + const executable = callArgs[0] == null ? null : sourceStaticStringContent(callArgs[0]!, source); + if (executable != null && isTailorCliTokenValue(executable)) return true; + } + + const elements = sourceArrayElements(arrayNode); + return elements.slice(0, index).some((element) => { + const value = sourceStaticStringContent(element, source); + return value != null && isTailorCliTokenValue(value); + }); +} + +function isCliValueArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() !== "array") return false; + const elements = sourceArrayElements(parent); + const index = nodeIndex(elements, node); + if (index < 0) return false; + if (!isTailorCliArgumentArray(parent, index, source)) return false; + const text = sourceStringContent(node, source) ?? sourceStringRawContent(node, source); + if ( + text != null && + text.includes("tailor-sdk") && + isTailorCliValueFlag(text) && + text.includes("=") + ) { + return true; + } + if (index === 0) return false; + const previousValue = sourceStaticStringContent(elements[index - 1]!, source); + return ( + text != null && + text.includes("tailor-sdk") && + previousValue != null && + isTailorCliValueFlag(previousValue) && + !previousValue.includes("=") + ); +} + +function pushSourceStringEdit( + edits: Array<[number, number, string]>, + source: string, + node: SgNode, +): void { + const range = node.range(); + const start = range.start.index + 1; + const end = range.end.index - 1; + const text = source.slice(start, end); + const packageFlagReplacement = sourcePackageFlagReplacement(node, source); + const replacement = + packageFlagReplacement != null + ? packageFlagReplacement.replacement + : TAILOR_SDK_TOKEN_RE.test(text) && isPackageRunnerPackageArgument(node, source) + ? renamePackageName(text) + : (TAILOR_SDK_TOKEN_RE.test(text) || TAILOR_SDK_PATH_RE.test(text)) && + isCliBinaryArgument(node, source) + ? renameBinary(text) + : isCliValueArgument(node, source) + ? text + : renameSourceCommandText(text); + if (replacement !== text) { + edits.push([start, end, replacement]); + } +} + +function templateSubstitutionPlaceholder( + index: number, + text: string, + usedPlaceholders: Set, +): string { + let attempt = 0; + while (true) { + const placeholder = `__TAILOR_SDK_TEMPLATE_EXPR_${index}_${attempt}__`; + if (!text.includes(placeholder) && !usedPlaceholders.has(placeholder)) { + usedPlaceholders.add(placeholder); + return placeholder; + } + attempt += 1; + } +} + +function restoreTemplateMigrationText( + text: string, + substitutions: ReadonlyArray<{ placeholder: string; migrationText: string }>, +): string { + let restored = text; + for (const substitution of substitutions) { + restored = restored.replaceAll(substitution.placeholder, () => substitution.migrationText); + } + return restored; +} + +function hasSourceTailorSdkReference(value: string): boolean { + TAILOR_SDK_RE.lastIndex = 0; + const matches = TAILOR_SDK_RE.test(value); + TAILOR_SDK_RE.lastIndex = 0; + return matches; +} + +function hasOnlyProtectedSourceCliValueLegacyToken(value: string): boolean { + const protectedValue = protectSourceCliValueReferences(value); + return ( + protectedValue.protectedValues.length > 0 && !hasSourceTailorSdkReference(protectedValue.source) + ); +} + +function templateSubstitutionMigrationText(node: SgNode, source: string, value: string): string { + const identifier = node.children().find((child) => child.kind() === "identifier"); + if (identifier != null && node.text() === `\${${identifier.text()}}`) { + const identifierValue = sourceStaticStringContent(identifier, source); + if (identifierValue != null) return identifierValue; + } + if (!value.startsWith("${") || !value.endsWith("}")) return value; + return value.slice(2, -1).trim(); +} + +function pushTemplateStringEdit( + edits: Array<[number, number, string]>, + source: string, + node: SgNode, +): void { + const range = node.range(); + const start = range.start.index + 1; + const end = range.end.index - 1; + let text = source.slice(start, end); + const substitutions: Array<{ placeholder: string; text: string; migrationText: string }> = []; + const usedPlaceholders = new Set(); + + const substitutionNodes = node + .children() + .filter((child: SgNode) => child.kind() === "template_substitution"); + for (const child of substitutionNodes.toReversed()) { + const childRange = child.range(); + const childStart = childRange.start.index - start; + const childEnd = childRange.end.index - start; + const placeholder = templateSubstitutionPlaceholder( + substitutions.length, + text, + usedPlaceholders, + ); + const substitutionText = isTemplateSubstitutionCliValue(text, childStart) + ? source.slice(childRange.start.index, childRange.end.index) + : transformTemplateSubstitutionText( + source.slice(childRange.start.index, childRange.end.index), + ); + substitutions.push({ + placeholder, + text: substitutionText, + migrationText: templateSubstitutionMigrationText(child, source, substitutionText), + }); + text = `${text.slice(0, childStart)}${placeholder}${text.slice(childEnd)}`; + } + + const migrationText = restoreTemplateMigrationText(text, substitutions); + if (hasOnlyProtectedSourceCliValueLegacyToken(migrationText)) { + return; + } + + let replacement = needsCliRenameMigration(migrationText) ? text : renameSourceCommandText(text); + for (const substitution of substitutions) { + replacement = replacement.replaceAll(substitution.placeholder, () => substitution.text); + } + if (replacement !== source.slice(start, end)) { + edits.push([start, end, replacement]); + } +} + +function transformTemplateSubstitutionText(value: string): string { + if (!value.includes("tailor-sdk") || !value.startsWith("${") || !value.endsWith("}")) { + return value; + } + const expression = value.slice(2, -1); + const transformed = transformSourceFile(expression, "template-expression.ts"); + return transformed == null ? value : `\${${transformed}}`; +} + +function transformSourceFile(source: string, filePath: string): string | null { + let root: SgNode; + try { + root = parse(sourceLang(filePath), source).root(); + } catch { + return null; + } + + const edits: Array<[number, number, string]> = []; + const visit = (node: SgNode): void => { + const kind = node.kind(); + const range = node.range(); + + if (kind === "comment" || kind === "jsx_text" || kind === "string_fragment") { + pushSourceTextEdit(edits, source, range.start.index, range.end.index); + return; + } + + if (kind === "string") { + pushSourceStringEdit(edits, source, node); + return; + } + + if (kind === "template_string") { + const hasSubstitution = node + .children() + .some((child: SgNode) => child.kind() === "template_substitution"); + if (!hasSubstitution) { + pushSourceStringEdit(edits, source, node); + return; + } + if (isCliValueArgument(node, source)) return; + pushTemplateStringEdit(edits, source, node); + return; + } + + for (const child of node.children()) { + visit(child); + } + }; + visit(root); + + if (edits.length === 0) return null; + let updated = source; + for (const [start, end, replacement] of edits.toSorted(([a], [b]) => b - a)) { + updated = `${updated.slice(0, start)}${replacement}${updated.slice(end)}`; + } + return updated === source ? null : updated; +} + +function transformPackageJson(source: string): string | null { + let parsed: Record; + try { + parsed = JSON.parse(source) as Record; + } catch { + return null; + } + + let modified = false; + const scripts = parsed.scripts; + if (typeof scripts === "object" && scripts != null && !Array.isArray(scripts)) { + for (const [name, value] of Object.entries(scripts as Record)) { + if (typeof value !== "string") continue; + if (!value.includes("tailor-sdk")) continue; + const updated = renameBinary(value); + if (updated !== value) { + (scripts as Record)[name] = updated; + modified = true; + } + } + } + + if (!modified) return null; + const trailing = source.endsWith("\n") ? "\n" : ""; + return JSON.stringify(parsed, null, 2) + trailing; +} + +/** + * Rename `tailor-sdk` binary references to `tailor`. + * + * Handles optional `@version` pins: + * - `npx tailor-sdk@latest` → `npx @tailor-platform/sdk@latest` (package-runner form) + * - `npx -y tailor-sdk login` → `npx -y @tailor-platform/sdk login` (runner flags preserved) + * - `pnpm dlx tailor-sdk@latest` → `pnpm dlx @tailor-platform/sdk@latest` (package-runner form) + * - `tailor-sdk@latest` elsewhere → `@tailor-platform/sdk@latest` + * Does not rewrite `.tailor-sdk` directory paths or `create-tailor-sdk`. + * @param source - File contents + * @param filePath - Absolute path to the file (used to dispatch package.json vs text) + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, filePath: string): string | null { + if (!source.includes("tailor-sdk")) return null; + + const ext = path.extname(filePath).toLowerCase(); + if (ext === ".json") return transformPackageJson(source); + if (SOURCE_EXTENSIONS.has(ext)) return transformSourceFile(source, filePath); + + const updated = renameBinary(source); + return updated === source ? null : updated; +} diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/expected.json b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/expected.json new file mode 100644 index 000000000..d8d47ae29 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/expected.json @@ -0,0 +1,10 @@ +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "deploy": "tailor deploy", + "login": "pnpm exec tailor login", + "generate": "@tailor-platform/sdk@latest generate", + "build": "tsc" + } +} diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/input.json b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/input.json new file mode 100644 index 000000000..0300c9746 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/input.json @@ -0,0 +1,10 @@ +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "deploy": "tailor-sdk deploy", + "login": "pnpm exec tailor-sdk login", + "generate": "tailor-sdk@latest generate", + "build": "tsc" + } +} diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/expected.sh b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/expected.sh new file mode 100644 index 000000000..1752dc093 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/expected.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm exec tailor deploy +tailor login +@tailor-platform/sdk@latest deploy +npx @tailor-platform/sdk@2.0.0 workspace list +npx -y @tailor-platform/sdk login diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/input.sh b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/input.sh new file mode 100644 index 000000000..e7743b6b7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/input.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm exec tailor-sdk deploy +tailor-sdk login +tailor-sdk@latest deploy +npx tailor-sdk@2.0.0 workspace list +npx -y tailor-sdk login diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/expected.yml b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/expected.yml new file mode 100644 index 000000000..91cb025a0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/expected.yml @@ -0,0 +1,5 @@ +steps: + - name: Deploy + run: tailor deploy -w ${{ secrets.WORKSPACE_ID }} + - name: Login check + run: npx @tailor-platform/sdk@latest login --help diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/input.yml b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/input.yml new file mode 100644 index 000000000..8c7f4a7e3 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/input.yml @@ -0,0 +1,5 @@ +steps: + - name: Deploy + run: tailor-sdk deploy -w ${{ secrets.WORKSPACE_ID }} + - name: Login check + run: npx tailor-sdk@latest login --help diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/expected.d.ts b/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/expected.d.ts new file mode 100644 index 000000000..6d5f65e20 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/expected.d.ts @@ -0,0 +1,2 @@ +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' +export {}; diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/input.d.ts b/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/input.d.ts new file mode 100644 index 000000000..e31ff90b5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/input.d.ts @@ -0,0 +1,2 @@ +// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +export {}; diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/no-match/input.sh b/packages/sdk-codemod/codemods/v2/rename-bin/tests/no-match/input.sh new file mode 100644 index 000000000..75da395ed --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/no-match/input.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# create-tailor-sdk is a scaffolding package, not the CLI binary +npx create-tailor-sdk@latest my-app + +# .tailor-sdk directory paths are not the binary +ls .tailor-sdk/cache +cat .tailor-sdk/config.json + +# tailor-sdk-skills has a trailing hyphen+word after the base token — not a binary invocation +tailor-sdk-skills diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/expected.js b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/expected.js new file mode 100644 index 000000000..19c241cf0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/expected.js @@ -0,0 +1,119 @@ +const script = "tailor deploy"; +const proseApply = "Run tailor deploy to apply changes"; +const spawned = spawn("tailor", ["deploy"]); +const optionOverloadSpawned = spawn("tailor", { stdio: "inherit" }); +const runtimeArg = getArg(); +const scopedRuntimeArgSpawned = spawn("tailor", [runtimeArg]); +const hiddenApplyArgs = ["apply"]; +const hiddenApplySpawned = spawn("tailor-sdk", hiddenApplyArgs); +const argSpawned = spawn("tailor", ["--arg", "tailor-sdk deploy", "deploy"]); +const tailorBin = "tailor"; +const aliasArgSpawned = spawn(tailorBin, ["--arg", "tailor-sdk deploy", "deploy"]); +const inlineArgSpawned = spawn("tailor", ["--arg=tailor-sdk deploy", "deploy"]); +const templateArgSpawned = spawn("tailor", ["--arg", `tailor-sdk ${cmd}`, "deploy"]); +const inlineTemplateArgSpawned = spawn("tailor", [`--arg=tailor-sdk ${cmd}`, "deploy"]); +const nameValueSpawned = spawn("tailor", ["tailordb", "migration", "generate", "--name", "tailor-sdk deploy"]); +const directNameValueSpawned = spawn("tailor", ["tailordb", "migration", "generate", "--name", "tailor-sdk deploy"]); +const shellSpawned = spawn("sh", ["-c", "tailor deploy"]); +const legacyApplyArg = "apply"; +const legacyMachineUserArg = "--machineuser"; +const legacyTemplateArg = "apply"; +const templateBin = "tailor"; +const templatedAliasArg = `${templateBin} --arg "tailor-sdk deploy" deploy`; +const applySpawned = spawn("tailor-sdk", ["apply"]); +const applyAliasSpawned = spawn("tailor-sdk", [legacyApplyArg]); +function unrelatedRuntimeArgScope() { + const runtimeArg = "apply"; + return runtimeArg; +} +function duplicateAliasOne() { + const duplicateLegacyArg = "apply"; + return spawn("tailor-sdk", [duplicateLegacyArg]); +} +function duplicateAliasTwo() { + const duplicateLegacyArg = "apply"; + return spawn("tailor-sdk", [duplicateLegacyArg]); +} +function conflictingAliasLegacy() { + const scopedArg = "apply"; + return spawn("tailor-sdk", [scopedArg]); +} +function conflictingAliasDeploy() { + const scopedArg = "deploy"; + return spawn("tailor", [scopedArg]); +} +const cliRenameCommandSpawned = spawn("tailor-sdk", ["crash-report", "list"]); +const cliRenameFlagSpawned = spawn("tailor-sdk", ["login", "--machineuser"]); +const cliRenameFlagAliasSpawned = spawn("tailor-sdk", ["login", legacyMachineUserArg]); +const secretValueApplySpawned = spawn("tailor", ["secret", "create", "--value", "apply"]); +const secretShortValueApplySpawned = spawn("tailor", ["secret", "create", "-v", "apply"]); +const dynamicCliRenameCommandSpawned = spawn("tailor-sdk", [`${"apply"}`]); +const dynamicCliRenameAliasCommand = `tailor-sdk ${legacyTemplateArg}`; +const npxSpawned = spawn("npx", ["@tailor-platform/sdk", "login"]); +const npxLegacyApplySpawned = spawn("npx", ["tailor-sdk", "apply"]); +const npxLegacyCrashReportSpawned = spawn("npx", ["tailor-sdk", "crash-report", "list"]); +const npxLegacyMachineUserSpawned = spawn("npx", ["tailor-sdk", "login", "--machineuser"]); +const npxOptionSpawned = spawn("npx", ["--yes", "@tailor-platform/sdk@latest", "login"]); +const npxProfileSpawned = spawn("npx", ["@tailor-platform/sdk", "--profile", "dev", "login"]); +const npxShortProfileSpawned = spawn("npx", ["@tailor-platform/sdk", "-p", "dev", "login"]); +const npxProfileNamedTailorSdkSpawned = spawn("npx", ["@tailor-platform/sdk", "--profile", "tailor-sdk", "login"]); +const npxShortProfileNamedTailorSdkSpawned = spawn("npx", ["@tailor-platform/sdk", "-p", "tailor-sdk", "login"]); +const npxInlineProfileNamedTailorSdkSpawned = spawn("npx", ["@tailor-platform/sdk", "-p=tailor-sdk", "login"]); +const npxVersionSpawned = spawn("npx", ["@tailor-platform/sdk", "--version"]); +const npxDynamicSpawned = spawn("npx", ["@tailor-platform/sdk", subcommand]); +const npxOtherPackageSpawned = spawn("npx", ["foo", "tailor-sdk", "login"]); +const npxOtherPackageFlagSpawned = spawn("npx", ["foo", "-p", "tailor-sdk", "tailor-sdk", "login"]); +const npxOtherPackageEqualsSpawned = spawn("npx", ["foo", "--package=tailor-sdk", "tailor-sdk", "login"]); +const npxOtherPackageSplitSpawned = spawn("npx", ["foo", "--package", "tailor-sdk", "tailor-sdk", "login"]); +const npxToolValueSpawned = spawn("npx", ["-p", "some-tool", "tool", "--name", "tailor-sdk", "deploy"]); +const npxPackageFlagSpawned = spawn("npx", ["-p", "@tailor-platform/sdk", "tailor", "login"]); +const npxPackageFlagWithRunnerOptionSpawned = spawn("npx", ["--package", "@tailor-platform/sdk", "--yes", "tailor", "login"]); +const npxMigratedPackageFlagWithRunnerOptionSpawned = spawn("npx", ["--package", "@tailor-platform/sdk", "--yes", "tailor", "login"]); +const npxMultiPackageFlagSpawned = spawn("npx", ["-p", "@tailor-platform/sdk", "-p", "dotenv-cli", "tailor", "login"]); +const npxMultiPackageFlagSecondSpawned = spawn("npx", ["-p", "dotenv-cli", "-p", "@tailor-platform/sdk", "tailor", "login"]); +const npxPackageEqualsSpawned = spawn("npx", ["--package=@tailor-platform/sdk", "tailor", "login"]); +const npxPackageFlagDynamicSpawned = spawn("npx", ["-p", "@tailor-platform/sdk", "tailor", subcommand]); +const npxPackageEqualsDynamicSpawned = spawn("npx", ["--package=@tailor-platform/sdk", "tailor", subcommand]); +const npxPackageMigratedSpawned = spawn("npx", ["--package", "@tailor-platform/sdk", "tailor", "login"]); +const npxPackageDynamicSpawned = spawn("npx", ["-p", pkg, "tailor-sdk", "login"]); +const npxOtherPackageCommandSpawned = spawn("npx", ["-p", "dotenv-cli", "tailor-sdk", "login"]); +const pnpmDlxSpawned = spawn("pnpm", ["dlx", "@tailor-platform/sdk", "login"]); +const pnpmDlxDynamicSpawned = spawn("pnpm", ["dlx", "@tailor-platform/sdk"]); +const pnpmDlxOtherPackageSpawned = spawn("pnpm", ["dlx", "foo", "tailor-sdk", "login"]); +const pnpmDlxOtherPackageFlagSpawned = spawn("pnpm", ["dlx", "foo", "-p", "tailor-sdk", "tailor-sdk", "login"]); +const pnpmDlxOptionSpawned = spawn("pnpm", ["--silent", "dlx", "@tailor-platform/sdk", "login"]); +const pnpmDlxSplitOptionSpawned = spawn("pnpm", ["--filter", "app", "dlx", "@tailor-platform/sdk", "login"]); +const pnpmDlxWorkspaceRootSpawned = spawn("pnpm", ["-w", "dlx", "@tailor-platform/sdk", "login"]); +const pnpmDlxRegistrySpawned = spawn("pnpm", ["--registry", registry, "dlx", "@tailor-platform/sdk", "login"]); +const pnpmExecSplitOptionSpawned = spawn("pnpm", ["--filter", "app", "exec", "tailor", "deploy"]); +const yarnDlxOptionSpawned = spawn("yarn", ["--quiet", "dlx", "@tailor-platform/sdk", "login"]); +const pnpmBinarySpawned = spawn("pnpm", ["tailor-sdk", "deploy"]); +const pnpmExecSpawned = spawn("pnpm", ["exec", "tailor", "deploy"]); +const pnpmExecDynamicSpawned = spawn("pnpm", ["exec", "tailor", subcommand]); +const pnpmExecHelpSpawned = spawn("pnpm", ["exec", "tailor", "--help"]); +const npmExecSpawned = spawn("npm", ["exec", "@tailor-platform/sdk", "login"]); +const npmExecWorkspaceSpawned = spawn("npm", ["-w", "app", "exec", "@tailor-platform/sdk", "login"]); +const npmExecLongWorkspaceSpawned = spawn("npm", [ + "--workspace", + "app", + "exec", + "@tailor-platform/sdk", + "login", +]); +const npmExecPackageFlagSpawned = spawn("npm", ["exec", "--package", "@tailor-platform/sdk", "tailor", "login"]); +const npmExecPackageEqualsSpawned = spawn("npm", ["exec", "--package=@tailor-platform/sdk", "tailor", "login"]); +const pathQualifiedSpawned = spawn("./node_modules/.bin/tailor", ["deploy"]); +const pathQualifiedArgSpawned = spawn("./node_modules/.bin/tailor", ["--arg", "tailor-sdk deploy", "deploy"]); +const packageDirectoryPathSpawned = spawn("./node_modules/tailor-sdk/bin/cli.js", ["deploy"]); +const windowsShimArgSpawned = spawn("tailor.cmd", ["--arg", "tailor-sdk deploy", "deploy"]); +const pathQualifiedWindowsShimArgSpawned = spawn("./node_modules/.bin/tailor.cmd", ["--arg", "tailor-sdk deploy", "deploy"]); +const arrayCommand = ["tailor-sdk", "--profile", "dev", "deploy"]; +const npxArgs = ["tailor-sdk", "login"]; +spawn("npx", npxArgs); +const docs = ( + <> +

package tailor-sdk is installed

+ tailor deploy + npx @tailor-platform/sdk@latest login + +); diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/input.js b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/input.js new file mode 100644 index 000000000..4e7c65ff0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/input.js @@ -0,0 +1,119 @@ +const script = "tailor-sdk deploy"; +const proseApply = "Run tailor-sdk deploy to apply changes"; +const spawned = spawn("tailor-sdk", ["deploy"]); +const optionOverloadSpawned = spawn("tailor-sdk", { stdio: "inherit" }); +const runtimeArg = getArg(); +const scopedRuntimeArgSpawned = spawn("tailor-sdk", [runtimeArg]); +const hiddenApplyArgs = ["apply"]; +const hiddenApplySpawned = spawn("tailor-sdk", hiddenApplyArgs); +const argSpawned = spawn("tailor-sdk", ["--arg", "tailor-sdk deploy", "deploy"]); +const tailorBin = "tailor"; +const aliasArgSpawned = spawn(tailorBin, ["--arg", "tailor-sdk deploy", "deploy"]); +const inlineArgSpawned = spawn("tailor-sdk", ["--arg=tailor-sdk deploy", "deploy"]); +const templateArgSpawned = spawn("tailor-sdk", ["--arg", `tailor-sdk ${cmd}`, "deploy"]); +const inlineTemplateArgSpawned = spawn("tailor-sdk", [`--arg=tailor-sdk ${cmd}`, "deploy"]); +const nameValueSpawned = spawn("tailor", ["tailordb", "migration", "generate", "--name", "tailor-sdk deploy"]); +const directNameValueSpawned = spawn("tailor-sdk", ["tailordb", "migration", "generate", "--name", "tailor-sdk deploy"]); +const shellSpawned = spawn("sh", ["-c", "tailor-sdk deploy"]); +const legacyApplyArg = "apply"; +const legacyMachineUserArg = "--machineuser"; +const legacyTemplateArg = "apply"; +const templateBin = "tailor"; +const templatedAliasArg = `${templateBin} --arg "tailor-sdk deploy" deploy`; +const applySpawned = spawn("tailor-sdk", ["apply"]); +const applyAliasSpawned = spawn("tailor-sdk", [legacyApplyArg]); +function unrelatedRuntimeArgScope() { + const runtimeArg = "apply"; + return runtimeArg; +} +function duplicateAliasOne() { + const duplicateLegacyArg = "apply"; + return spawn("tailor-sdk", [duplicateLegacyArg]); +} +function duplicateAliasTwo() { + const duplicateLegacyArg = "apply"; + return spawn("tailor-sdk", [duplicateLegacyArg]); +} +function conflictingAliasLegacy() { + const scopedArg = "apply"; + return spawn("tailor-sdk", [scopedArg]); +} +function conflictingAliasDeploy() { + const scopedArg = "deploy"; + return spawn("tailor-sdk", [scopedArg]); +} +const cliRenameCommandSpawned = spawn("tailor-sdk", ["crash-report", "list"]); +const cliRenameFlagSpawned = spawn("tailor-sdk", ["login", "--machineuser"]); +const cliRenameFlagAliasSpawned = spawn("tailor-sdk", ["login", legacyMachineUserArg]); +const secretValueApplySpawned = spawn("tailor-sdk", ["secret", "create", "--value", "apply"]); +const secretShortValueApplySpawned = spawn("tailor-sdk", ["secret", "create", "-v", "apply"]); +const dynamicCliRenameCommandSpawned = spawn("tailor-sdk", [`${"apply"}`]); +const dynamicCliRenameAliasCommand = `tailor-sdk ${legacyTemplateArg}`; +const npxSpawned = spawn("npx", ["tailor-sdk", "login"]); +const npxLegacyApplySpawned = spawn("npx", ["tailor-sdk", "apply"]); +const npxLegacyCrashReportSpawned = spawn("npx", ["tailor-sdk", "crash-report", "list"]); +const npxLegacyMachineUserSpawned = spawn("npx", ["tailor-sdk", "login", "--machineuser"]); +const npxOptionSpawned = spawn("npx", ["--yes", "tailor-sdk@latest", "login"]); +const npxProfileSpawned = spawn("npx", ["tailor-sdk", "--profile", "dev", "login"]); +const npxShortProfileSpawned = spawn("npx", ["tailor-sdk", "-p", "dev", "login"]); +const npxProfileNamedTailorSdkSpawned = spawn("npx", ["tailor-sdk", "--profile", "tailor-sdk", "login"]); +const npxShortProfileNamedTailorSdkSpawned = spawn("npx", ["tailor-sdk", "-p", "tailor-sdk", "login"]); +const npxInlineProfileNamedTailorSdkSpawned = spawn("npx", ["tailor-sdk", "-p=tailor-sdk", "login"]); +const npxVersionSpawned = spawn("npx", ["tailor-sdk", "--version"]); +const npxDynamicSpawned = spawn("npx", ["tailor-sdk", subcommand]); +const npxOtherPackageSpawned = spawn("npx", ["foo", "tailor-sdk", "login"]); +const npxOtherPackageFlagSpawned = spawn("npx", ["foo", "-p", "tailor-sdk", "tailor-sdk", "login"]); +const npxOtherPackageEqualsSpawned = spawn("npx", ["foo", "--package=tailor-sdk", "tailor-sdk", "login"]); +const npxOtherPackageSplitSpawned = spawn("npx", ["foo", "--package", "tailor-sdk", "tailor-sdk", "login"]); +const npxToolValueSpawned = spawn("npx", ["-p", "some-tool", "tool", "--name", "tailor-sdk", "deploy"]); +const npxPackageFlagSpawned = spawn("npx", ["-p", "tailor-sdk", "tailor-sdk", "login"]); +const npxPackageFlagWithRunnerOptionSpawned = spawn("npx", ["--package", "tailor-sdk", "--yes", "tailor-sdk", "login"]); +const npxMigratedPackageFlagWithRunnerOptionSpawned = spawn("npx", ["--package", "@tailor-platform/sdk", "--yes", "tailor-sdk", "login"]); +const npxMultiPackageFlagSpawned = spawn("npx", ["-p", "tailor-sdk", "-p", "dotenv-cli", "tailor-sdk", "login"]); +const npxMultiPackageFlagSecondSpawned = spawn("npx", ["-p", "dotenv-cli", "-p", "tailor-sdk", "tailor-sdk", "login"]); +const npxPackageEqualsSpawned = spawn("npx", ["--package=tailor-sdk", "tailor-sdk", "login"]); +const npxPackageFlagDynamicSpawned = spawn("npx", ["-p", "tailor-sdk", "tailor-sdk", subcommand]); +const npxPackageEqualsDynamicSpawned = spawn("npx", ["--package=tailor-sdk", "tailor-sdk", subcommand]); +const npxPackageMigratedSpawned = spawn("npx", ["--package", "@tailor-platform/sdk", "tailor-sdk", "login"]); +const npxPackageDynamicSpawned = spawn("npx", ["-p", pkg, "tailor-sdk", "login"]); +const npxOtherPackageCommandSpawned = spawn("npx", ["-p", "dotenv-cli", "tailor-sdk", "login"]); +const pnpmDlxSpawned = spawn("pnpm", ["dlx", "tailor-sdk", "login"]); +const pnpmDlxDynamicSpawned = spawn("pnpm", ["dlx", "tailor-sdk"]); +const pnpmDlxOtherPackageSpawned = spawn("pnpm", ["dlx", "foo", "tailor-sdk", "login"]); +const pnpmDlxOtherPackageFlagSpawned = spawn("pnpm", ["dlx", "foo", "-p", "tailor-sdk", "tailor-sdk", "login"]); +const pnpmDlxOptionSpawned = spawn("pnpm", ["--silent", "dlx", "tailor-sdk", "login"]); +const pnpmDlxSplitOptionSpawned = spawn("pnpm", ["--filter", "app", "dlx", "tailor-sdk", "login"]); +const pnpmDlxWorkspaceRootSpawned = spawn("pnpm", ["-w", "dlx", "tailor-sdk", "login"]); +const pnpmDlxRegistrySpawned = spawn("pnpm", ["--registry", registry, "dlx", "tailor-sdk", "login"]); +const pnpmExecSplitOptionSpawned = spawn("pnpm", ["--filter", "app", "exec", "tailor-sdk", "deploy"]); +const yarnDlxOptionSpawned = spawn("yarn", ["--quiet", "dlx", "tailor-sdk", "login"]); +const pnpmBinarySpawned = spawn("pnpm", ["tailor-sdk", "deploy"]); +const pnpmExecSpawned = spawn("pnpm", ["exec", "tailor-sdk", "deploy"]); +const pnpmExecDynamicSpawned = spawn("pnpm", ["exec", "tailor-sdk", subcommand]); +const pnpmExecHelpSpawned = spawn("pnpm", ["exec", "tailor-sdk", "--help"]); +const npmExecSpawned = spawn("npm", ["exec", "tailor-sdk", "login"]); +const npmExecWorkspaceSpawned = spawn("npm", ["-w", "app", "exec", "tailor-sdk", "login"]); +const npmExecLongWorkspaceSpawned = spawn("npm", [ + "--workspace", + "app", + "exec", + "tailor-sdk", + "login", +]); +const npmExecPackageFlagSpawned = spawn("npm", ["exec", "--package", "tailor-sdk", "tailor-sdk", "login"]); +const npmExecPackageEqualsSpawned = spawn("npm", ["exec", "--package=tailor-sdk", "tailor-sdk", "login"]); +const pathQualifiedSpawned = spawn("./node_modules/.bin/tailor-sdk", ["deploy"]); +const pathQualifiedArgSpawned = spawn("./node_modules/.bin/tailor-sdk", ["--arg", "tailor-sdk deploy", "deploy"]); +const packageDirectoryPathSpawned = spawn("./node_modules/tailor-sdk/bin/cli.js", ["deploy"]); +const windowsShimArgSpawned = spawn("tailor-sdk.cmd", ["--arg", "tailor-sdk deploy", "deploy"]); +const pathQualifiedWindowsShimArgSpawned = spawn("./node_modules/.bin/tailor-sdk.cmd", ["--arg", "tailor-sdk deploy", "deploy"]); +const arrayCommand = ["tailor-sdk", "--profile", "dev", "deploy"]; +const npxArgs = ["tailor-sdk", "login"]; +spawn("npx", npxArgs); +const docs = ( + <> +

package tailor-sdk is installed

+ tailor-sdk deploy + npx tailor-sdk@latest login + +); diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/expected.ts b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/expected.ts new file mode 100644 index 000000000..95a2434a2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/expected.ts @@ -0,0 +1,102 @@ +const siteName = "portal"; +const setup = `pnpm tailor staticwebsite deploy --name ${siteName}`; +const deploy = "tailor deploy"; +const envFileDeploy = "tailor --env-file .env deploy"; +const profileValue = "tailor --profile tailor-sdk deploy"; +const pnpmExecProfileValue = "pnpm exec tailor --profile tailor-sdk deploy"; +const yarnExecProfileValue = "yarn exec tailor --profile tailor-sdk deploy"; +const nameValue = "tailor tailordb migration generate --name \"tailor-sdk deploy\""; +const directNameValue = "tailor tailordb migration generate --name \"tailor-sdk deploy\""; +const namespaceValue = "tailor tailordb truncate --namespace tailor-sdk"; +const dirValue = "tailor staticwebsite deploy --name site --dir tailor-sdk"; +const pathQualifiedArgValue = "./node_modules/.bin/tailor --arg \"tailor-sdk deploy\" deploy"; +const placeholderCollision = "tailor deploy --arg \"tailor-sdk deploy\" __TAILOR_SDK_SOURCE_VALUE_0_0__"; +const pathQualifiedCliRenameCommand = "./node_modules/.bin/tailor-sdk apply"; +const help = "tailor --help"; +const optionHelp = "tailor --env-file .env --help"; +const optionVersion = "tailor --profile dev --version"; +const windowsShimDeploy = "tailor.cmd deploy"; +const pathQualifiedWindowsShimDeploy = "./node_modules/.bin/tailor.ps1 deploy"; +const npxVersion = "npx @tailor-platform/sdk --version"; +const npmExecWorkspace = "npm -w app exec @tailor-platform/sdk login"; +const npmExecLongWorkspace = "npm --workspace app exec @tailor-platform/sdk login"; +const generated = "Run tailor generate after changes"; +const cliRenameCommand = "tailor-sdk crash-report list"; +const legacyConstApplyArg = "apply" as const; +const applyConstAliasSpawned = spawn("tailor-sdk", [legacyConstApplyArg]); +const windowsShimCliRenameCommand = "tailor-sdk.cmd crash-report list"; +const cliRenameFlag = "tailor-sdk login --machineuser"; +const shellWrappedCliRenameCommand = "sh -c \"tailor-sdk apply\""; +const dynamicCommand = `tailor ${subcommand}`; +const dynamicCliRenameCommand = `tailor-sdk ${"apply"}`; +const dynamicCommandWithTrailingFlag = `tailor ${subcommand} --json`; +const dynamicCommandBeforeSeparator = `tailor ${subcommand} && echo done`; +const dynamicPnpmCommand = `pnpm tailor ${subcommand}`; +const dynamicPnpmCommandBeforePipe = `pnpm tailor ${subcommand} | jq`; +const dynamicProfileCommand = `tailor --profile ${profile} deploy`; +const dynamicEqualsProfileCommand = `tailor --profile=${profile} deploy`; +const dynamicEqualsWorkspaceCommand = `tailor --workspace-id=${workspaceId} deploy`; +const indentedDynamicCommand = ` + tailor ${subcommand}`; +const latest = "npx @tailor-platform/sdk@latest login"; +const latestOnly = "npx @tailor-platform/sdk@latest"; +const latestWithRunnerOption = "npx --yes @tailor-platform/sdk@latest login"; +const dynamicNpxCommand = `npx @tailor-platform/sdk ${subcommand}`; +const dynamicNpxRegistryCommand = `npx --registry ${registry} @tailor-platform/sdk login`; +const dynamicRunnerCommand = `${runner} tailor-sdk login`; +const literalPlaceholderCommand = `prefix __TAILOR_SDK_TEMPLATE_EXPR_0__ tailor-sdk ${subcommand}`; +const npxOtherPackage = "npx foo tailor-sdk login"; +const npxBooleanOtherPackage = "npx --yes foo tailor-sdk login"; +const npxOtherPackageFlag = "npx foo -p tailor-sdk tailor-sdk login"; +const npxOtherPackageFlagEquals = "npx foo --package=tailor-sdk tailor-sdk login"; +const npxQuotedPackage = "npx \"@tailor-platform/sdk\" login"; +const dynamicBunxCommand = `bunx @tailor-platform/sdk ${subcommand}`; +const dynamicDlxCommand = `pnpm dlx @tailor-platform/sdk ${subcommand}`; +const pnpmDlxWithOption = "pnpm --silent dlx @tailor-platform/sdk login"; +const pnpmDlxWithOptionValue = "pnpm --filter app dlx @tailor-platform/sdk login"; +const pnpmWorkspaceRootDlx = "pnpm -w dlx @tailor-platform/sdk login"; +const pnpmFilterNamedDlxCommand = "pnpm --filter dlx tailor deploy"; +const pnpmDlxAfterFilterNamedDlx = "pnpm --filter dlx dlx @tailor-platform/sdk login"; +const pnpmDlxBooleanOtherPackage = "pnpm dlx --yes foo tailor-sdk login"; +const dynamicPnpmDlxWithOptionValue = `pnpm --filter ${app} dlx @tailor-platform/sdk login`; +const yarnDlxWithOption = "yarn --quiet dlx @tailor-platform/sdk login"; +const nestedCommand = `run ${"tailor deploy"}`; +const nestedTailorCommand = `tailor deploy ${"tailor login"}`; +const nestedArgValue = `tailor --arg ${"tailor-sdk deploy"} deploy`; +const nestedInlineArgValue = `tailor --arg=${"tailor-sdk deploy"} deploy`; +const npxPackageFlag = "npx -p @tailor-platform/sdk tailor login"; +const npxMultiPackageFlag = "npx -p @tailor-platform/sdk -p dotenv-cli tailor login"; +const npxMultiPackageFlagSecond = "npx -p dotenv-cli -p @tailor-platform/sdk tailor login"; +const npxPackageFlagWithRunnerOption = "npx --package @tailor-platform/sdk --yes tailor login"; +const npxPackageSplitOnly = "npx --package @tailor-platform/sdk"; +const npxPackageSplitHelp = "npx -p @tailor-platform/sdk --help"; +const npxPackageFlagEquals = "npx --package=@tailor-platform/sdk tailor login"; +const npxPackageSingleQuoted = "npx --package '@tailor-platform/sdk' tailor login"; +const npxPackageDoubleQuoted = "npx --package \"@tailor-platform/sdk\" tailor login"; +const npxPackageEqualsSingleQuoted = "npx --package='@tailor-platform/sdk' tailor login"; +const npxPackageEqualsDoubleQuoted = "npx --package=\"@tailor-platform/sdk\" tailor login"; +const npxPackageDoubleQuotedBeforeAnd = "npx --package \"@tailor-platform/sdk\" tailor login && echo done"; +const npxDynamicPackageFlag = `npx -p ${pkg} tailor-sdk login`; +const npxDynamicPackageFlagEquals = `npx --package=${pkg} tailor-sdk login`; +const npxOtherPackageFlagEqualsSource = "npx --package=dotenv-cli tailor-sdk login"; +const npxPackageFlagDynamic = `npx -p @tailor-platform/sdk tailor ${subcommand}`; +const npxPackageEqualsDynamic = `npx --package=@tailor-platform/sdk tailor ${subcommand}`; +const npxRegistryValue = "npx --registry tailor-sdk @tailor-platform/sdk login"; +const npxRegistryPackageFlag = "npx --registry https://registry.npmjs.org -p @tailor-platform/sdk tailor login"; +const npxProfileValue = "npx @tailor-platform/sdk -p tailor-sdk login"; +const pnpmDlxPackageFlag = "pnpm dlx --package @tailor-platform/sdk tailor login"; +const pnpmDlxPackageFlagEquals = "pnpm dlx --package=@tailor-platform/sdk tailor login"; +const yarnDlxPackageFlag = "yarn dlx --package @tailor-platform/sdk tailor login"; +const pnpmDlxOtherPackageFlag = "pnpm dlx foo -p tailor-sdk tailor-sdk login"; +const npxOtherPackageQuoted = "npx foo \"tailor-sdk login\""; +const npxOtherPackageSingleQuoted = "npx foo 'tailor-sdk login'"; +const npxOtherPackageEscapedQuoteValue = "npx foo \"hello\\\"tailor-sdk login\""; +const shellWrapped = "sh -c \"tailor deploy\""; +const escapedArg = "tailor --arg \"tailor-sdk deploy\" deploy"; +const dollarArg = "tailor --arg \"$& tailor-sdk deploy\" deploy"; +const packageName = "tailor-sdk"; +const packageMessage = "package tailor-sdk is installed"; +const mixedPackageAndCommand = "Install tailor-sdk before running tailor deploy"; +const outputDir = ".tailor-sdk/"; +const scaffold = "create-tailor-sdk"; +const skills = "tailor-sdk-skills install"; diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/input.ts b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/input.ts new file mode 100644 index 000000000..3d2266f05 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/input.ts @@ -0,0 +1,102 @@ +const siteName = "portal"; +const setup = `pnpm tailor-sdk staticwebsite deploy --name ${siteName}`; +const deploy = "tailor-sdk deploy"; +const envFileDeploy = "tailor-sdk --env-file .env deploy"; +const profileValue = "tailor-sdk --profile tailor-sdk deploy"; +const pnpmExecProfileValue = "pnpm exec tailor-sdk --profile tailor-sdk deploy"; +const yarnExecProfileValue = "yarn exec tailor-sdk --profile tailor-sdk deploy"; +const nameValue = "tailor tailordb migration generate --name \"tailor-sdk deploy\""; +const directNameValue = "tailor-sdk tailordb migration generate --name \"tailor-sdk deploy\""; +const namespaceValue = "tailor tailordb truncate --namespace tailor-sdk"; +const dirValue = "tailor staticwebsite deploy --name site --dir tailor-sdk"; +const pathQualifiedArgValue = "./node_modules/.bin/tailor-sdk --arg \"tailor-sdk deploy\" deploy"; +const placeholderCollision = "tailor-sdk deploy --arg \"tailor-sdk deploy\" __TAILOR_SDK_SOURCE_VALUE_0_0__"; +const pathQualifiedCliRenameCommand = "./node_modules/.bin/tailor-sdk apply"; +const help = "tailor-sdk --help"; +const optionHelp = "tailor-sdk --env-file .env --help"; +const optionVersion = "tailor-sdk --profile dev --version"; +const windowsShimDeploy = "tailor-sdk.cmd deploy"; +const pathQualifiedWindowsShimDeploy = "./node_modules/.bin/tailor-sdk.ps1 deploy"; +const npxVersion = "npx tailor-sdk --version"; +const npmExecWorkspace = "npm -w app exec tailor-sdk login"; +const npmExecLongWorkspace = "npm --workspace app exec tailor-sdk login"; +const generated = "Run tailor-sdk generate after changes"; +const cliRenameCommand = "tailor-sdk crash-report list"; +const legacyConstApplyArg = "apply" as const; +const applyConstAliasSpawned = spawn("tailor-sdk", [legacyConstApplyArg]); +const windowsShimCliRenameCommand = "tailor-sdk.cmd crash-report list"; +const cliRenameFlag = "tailor-sdk login --machineuser"; +const shellWrappedCliRenameCommand = "sh -c \"tailor-sdk apply\""; +const dynamicCommand = `tailor-sdk ${subcommand}`; +const dynamicCliRenameCommand = `tailor-sdk ${"apply"}`; +const dynamicCommandWithTrailingFlag = `tailor-sdk ${subcommand} --json`; +const dynamicCommandBeforeSeparator = `tailor-sdk ${subcommand} && echo done`; +const dynamicPnpmCommand = `pnpm tailor-sdk ${subcommand}`; +const dynamicPnpmCommandBeforePipe = `pnpm tailor-sdk ${subcommand} | jq`; +const dynamicProfileCommand = `tailor-sdk --profile ${profile} deploy`; +const dynamicEqualsProfileCommand = `tailor-sdk --profile=${profile} deploy`; +const dynamicEqualsWorkspaceCommand = `tailor-sdk --workspace-id=${workspaceId} deploy`; +const indentedDynamicCommand = ` + tailor-sdk ${subcommand}`; +const latest = "npx tailor-sdk@latest login"; +const latestOnly = "npx tailor-sdk@latest"; +const latestWithRunnerOption = "npx --yes tailor-sdk@latest login"; +const dynamicNpxCommand = `npx tailor-sdk ${subcommand}`; +const dynamicNpxRegistryCommand = `npx --registry ${registry} tailor-sdk login`; +const dynamicRunnerCommand = `${runner} tailor-sdk login`; +const literalPlaceholderCommand = `prefix __TAILOR_SDK_TEMPLATE_EXPR_0__ tailor-sdk ${subcommand}`; +const npxOtherPackage = "npx foo tailor-sdk login"; +const npxBooleanOtherPackage = "npx --yes foo tailor-sdk login"; +const npxOtherPackageFlag = "npx foo -p tailor-sdk tailor-sdk login"; +const npxOtherPackageFlagEquals = "npx foo --package=tailor-sdk tailor-sdk login"; +const npxQuotedPackage = "npx \"tailor-sdk\" login"; +const dynamicBunxCommand = `bunx tailor-sdk ${subcommand}`; +const dynamicDlxCommand = `pnpm dlx tailor-sdk ${subcommand}`; +const pnpmDlxWithOption = "pnpm --silent dlx tailor-sdk login"; +const pnpmDlxWithOptionValue = "pnpm --filter app dlx tailor-sdk login"; +const pnpmWorkspaceRootDlx = "pnpm -w dlx tailor-sdk login"; +const pnpmFilterNamedDlxCommand = "pnpm --filter dlx tailor-sdk deploy"; +const pnpmDlxAfterFilterNamedDlx = "pnpm --filter dlx dlx tailor-sdk login"; +const pnpmDlxBooleanOtherPackage = "pnpm dlx --yes foo tailor-sdk login"; +const dynamicPnpmDlxWithOptionValue = `pnpm --filter ${app} dlx tailor-sdk login`; +const yarnDlxWithOption = "yarn --quiet dlx tailor-sdk login"; +const nestedCommand = `run ${"tailor-sdk deploy"}`; +const nestedTailorCommand = `tailor deploy ${"tailor-sdk login"}`; +const nestedArgValue = `tailor-sdk --arg ${"tailor-sdk deploy"} deploy`; +const nestedInlineArgValue = `tailor-sdk --arg=${"tailor-sdk deploy"} deploy`; +const npxPackageFlag = "npx -p tailor-sdk tailor-sdk login"; +const npxMultiPackageFlag = "npx -p tailor-sdk -p dotenv-cli tailor-sdk login"; +const npxMultiPackageFlagSecond = "npx -p dotenv-cli -p tailor-sdk tailor-sdk login"; +const npxPackageFlagWithRunnerOption = "npx --package tailor-sdk --yes tailor-sdk login"; +const npxPackageSplitOnly = "npx --package tailor-sdk"; +const npxPackageSplitHelp = "npx -p tailor-sdk --help"; +const npxPackageFlagEquals = "npx --package=tailor-sdk tailor-sdk login"; +const npxPackageSingleQuoted = "npx --package 'tailor-sdk' tailor-sdk login"; +const npxPackageDoubleQuoted = "npx --package \"tailor-sdk\" tailor-sdk login"; +const npxPackageEqualsSingleQuoted = "npx --package='tailor-sdk' tailor-sdk login"; +const npxPackageEqualsDoubleQuoted = "npx --package=\"tailor-sdk\" tailor-sdk login"; +const npxPackageDoubleQuotedBeforeAnd = "npx --package \"tailor-sdk\" tailor-sdk login && echo done"; +const npxDynamicPackageFlag = `npx -p ${pkg} tailor-sdk login`; +const npxDynamicPackageFlagEquals = `npx --package=${pkg} tailor-sdk login`; +const npxOtherPackageFlagEqualsSource = "npx --package=dotenv-cli tailor-sdk login"; +const npxPackageFlagDynamic = `npx -p tailor-sdk tailor-sdk ${subcommand}`; +const npxPackageEqualsDynamic = `npx --package=tailor-sdk tailor-sdk ${subcommand}`; +const npxRegistryValue = "npx --registry tailor-sdk tailor-sdk login"; +const npxRegistryPackageFlag = "npx --registry https://registry.npmjs.org -p tailor-sdk tailor-sdk login"; +const npxProfileValue = "npx tailor-sdk -p tailor-sdk login"; +const pnpmDlxPackageFlag = "pnpm dlx --package tailor-sdk tailor-sdk login"; +const pnpmDlxPackageFlagEquals = "pnpm dlx --package=tailor-sdk tailor-sdk login"; +const yarnDlxPackageFlag = "yarn dlx --package tailor-sdk tailor-sdk login"; +const pnpmDlxOtherPackageFlag = "pnpm dlx foo -p tailor-sdk tailor-sdk login"; +const npxOtherPackageQuoted = "npx foo \"tailor-sdk login\""; +const npxOtherPackageSingleQuoted = "npx foo 'tailor-sdk login'"; +const npxOtherPackageEscapedQuoteValue = "npx foo \"hello\\\"tailor-sdk login\""; +const shellWrapped = "sh -c \"tailor-sdk deploy\""; +const escapedArg = "tailor-sdk --arg \"tailor-sdk deploy\" deploy"; +const dollarArg = "tailor-sdk --arg \"$& tailor-sdk deploy\" deploy"; +const packageName = "tailor-sdk"; +const packageMessage = "package tailor-sdk is installed"; +const mixedPackageAndCommand = "Install tailor-sdk before running tailor-sdk deploy"; +const outputDir = ".tailor-sdk/"; +const scaffold = "create-tailor-sdk"; +const skills = "tailor-sdk-skills install"; diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/expected.sh b/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/expected.sh new file mode 100644 index 000000000..5cf703319 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/expected.sh @@ -0,0 +1,7 @@ +@tailor-platform/sdk@latest deploy -w my-workspace +pnpm dlx @tailor-platform/sdk@2.0.0 login +yarn dlx @tailor-platform/sdk@latest login +bunx @tailor-platform/sdk@1.0.0 login +npx @tailor-platform/sdk@latest login +npx @tailor-platform/sdk login +npx -y @tailor-platform/sdk@latest login diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/input.sh b/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/input.sh new file mode 100644 index 000000000..25ef731c5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/input.sh @@ -0,0 +1,7 @@ +tailor-sdk@latest deploy -w my-workspace +pnpm dlx tailor-sdk@2.0.0 login +yarn dlx tailor-sdk@latest login +bunx tailor-sdk@1.0.0 login +npx tailor-sdk@latest login +npx tailor-sdk login +npx -y tailor-sdk@latest login diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/codemod.yaml b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/codemod.yaml new file mode 100644 index 000000000..a00bf5058 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/runtime-globals-opt-in" +version: "1.0.0" +description: "Rewrite simple direct tailor.idp.Client runtime global usage to the typed idp wrapper and flag broader runtime globals for review" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/scripts/transform.ts new file mode 100644 index 000000000..b0d0521c2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/scripts/transform.ts @@ -0,0 +1,156 @@ +import { parse, Lang } from "@ast-grep/napi"; +import { + buildAddNamedImportEdit, + findImportStatements, + importBindings, + localDeclarationNames, +} from "../../../../src/ast-grep-helpers"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const RUNTIME_MODULE = "@tailor-platform/sdk/runtime"; +const TAILOR_IDP_CLIENT = "tailor.idp.Client"; +const NON_ARGUMENT_KINDS = new Set(["(", ")", ",", "comment"]); + +function quickFilter(source: string): boolean { + return source.includes(TAILOR_IDP_CLIENT); +} + +function sourceLang(filePath: string, source: string): Lang { + return filePath.endsWith(".tsx") || filePath.endsWith(".jsx") || source.includes("[number]): boolean { + return binding.source === RUNTIME_MODULE && binding.importedName === "idp"; +} + +function hasCollision( + imports: SgNode[], + localNames: Set, + idpLocal: string, + injectingNewIdpName: boolean, +): boolean { + if ( + localNames.has("tailor") || + (injectingNewIdpName && localNames.has("idp")) || + localNames.has(idpLocal) + ) + return true; + + for (const importStmt of imports) { + for (const binding of importBindings(importStmt)) { + if (binding.localName === "tailor") return true; + if (binding.localName !== "idp") continue; + if (isRuntimeIdpBinding(binding)) continue; + return true; + } + } + + return false; +} + +function isDirectiveStatement(node: SgNode): boolean { + return node.kind() === "expression_statement" && node.children()[0]?.kind() === "string"; +} + +function importInsertionIndex(root: SgNode, imports: SgNode[], source: string): number { + const lastImport = imports.at(-1); + if (lastImport) return lastImport.range().end.index; + + let pos = 0; + if (source.startsWith("#!")) { + const newlineIndex = source.indexOf("\n"); + pos = newlineIndex === -1 ? source.length : newlineIndex + 1; + } + + for (const child of root.children()) { + if (child.range().start.index < pos) continue; + if (child.kind() === "comment") { + pos = child.range().end.index; + continue; + } + if (!isDirectiveStatement(child)) break; + pos = child.range().end.index; + } + + return pos; +} + +function buildAddRuntimeImportEdit(root: SgNode, source: string, imports: SgNode[]): Edit { + return buildAddNamedImportEdit({ + importName: "idp", + imports, + insertionIndex: importInsertionIndex, + moduleName: RUNTIME_MODULE, + root, + source, + }); +} + +function argumentExpressions(args: SgNode): SgNode[] { + return args.children().filter((child) => !NON_ARGUMENT_KINDS.has(child.kind() as string)); +} + +function hasConstructorArguments(newExpression: SgNode): boolean { + const args = newExpression.field("arguments"); + return args ? argumentExpressions(args).length > 0 : false; +} + +function findTailorIdpClientConstructors(root: SgNode): SgNode[] { + return root + .findAll({ rule: { kind: "new_expression" } }) + .filter(hasConstructorArguments) + .map((node) => node.field("constructor")) + .filter((node): node is SgNode => node?.text() === TAILOR_IDP_CLIENT); +} + +/** + * Rewrite direct `new tailor.idp.Client(...)` calls to the typed runtime + * wrapper. Files with local `tailor` or conflicting `idp` bindings are left + * unchanged for the runtime-globals review prompt. + * @param source - File contents + * @param filePath - Absolute path to the file + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, filePath: string): string | null { + if (!quickFilter(source)) return null; + + const root = parse(sourceLang(filePath, source), source).root(); + const constructors = findTailorIdpClientConstructors(root); + if (constructors.length === 0) return null; + + const imports = findImportStatements(root); + const existingIdpLocal = runtimeIdpLocalName(imports); + const idpLocal = existingIdpLocal ?? "idp"; + if (hasCollision(imports, localDeclarationNames(root), idpLocal, existingIdpLocal === null)) { + return null; + } + + const edits: Edit[] = constructors.map((constructor) => + constructor.replace(`${idpLocal}.Client`), + ); + + if (!existingIdpLocal) { + if (filePath.endsWith(".cts")) return null; + edits.push(buildAddRuntimeImportEdit(root, source, imports)); + } + + const result = root.commitEdits(edits); + return result === source ? null : result; +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/expected.ts new file mode 100644 index 000000000..8903f6607 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/expected.ts @@ -0,0 +1,8 @@ +import { idp as runtimeIdp } from "@tailor-platform/sdk/runtime"; + +const idp = createLocalIdp(); + +export async function run() { + const client = new runtimeIdp.Client({ namespace: "default" }); + return client.listUsers(idp); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/input.ts new file mode 100644 index 000000000..3b3fb0433 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/input.ts @@ -0,0 +1,8 @@ +import { idp as runtimeIdp } from "@tailor-platform/sdk/runtime"; + +const idp = createLocalIdp(); + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(idp); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-local/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-local/input.ts new file mode 100644 index 000000000..41d31b8e4 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-local/input.ts @@ -0,0 +1,6 @@ +import { idp as runtimeIdp } from "@tailor-platform/sdk/runtime"; + +export const run = (runtimeIdp: unknown) => { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +}; diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/arrow-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/arrow-idp/input.ts new file mode 100644 index 000000000..527d16470 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/arrow-idp/input.ts @@ -0,0 +1,4 @@ +export const run = idp => { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +}; diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/expected.ts new file mode 100644 index 000000000..2f61259b2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/expected.ts @@ -0,0 +1,6 @@ +import { idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const client = new idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/input.ts new file mode 100644 index 000000000..e21a2970c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/input.ts @@ -0,0 +1,4 @@ +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-idp/input.ts new file mode 100644 index 000000000..f3c34f04d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-idp/input.ts @@ -0,0 +1,8 @@ +export async function run() { + try { + return await load(); + } catch (idp) { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); + } +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-pattern-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-pattern-idp/input.ts new file mode 100644 index 000000000..7b70c382d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-pattern-idp/input.ts @@ -0,0 +1,8 @@ +export async function run() { + try { + return await load(); + } catch ({ idp }) { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); + } +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/comment-only-args/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/comment-only-args/input.ts new file mode 100644 index 000000000..2fcb13ef2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/comment-only-args/input.ts @@ -0,0 +1,4 @@ +export async function run() { + const client = new tailor.idp.Client(/* namespace required */); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/cts-auto-import/input.cts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/cts-auto-import/input.cts new file mode 100644 index 000000000..e21a2970c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/cts-auto-import/input.cts @@ -0,0 +1,4 @@ +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/expected.ts new file mode 100644 index 000000000..60671aa7a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/expected.ts @@ -0,0 +1,4 @@ +"use server"; +import { idp } from "@tailor-platform/sdk/runtime"; + +export const client = new idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/input.ts new file mode 100644 index 000000000..4ce7d57f7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/input.ts @@ -0,0 +1,3 @@ +"use server"; + +export const client = new tailor.idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/expected.ts new file mode 100644 index 000000000..2f61259b2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/expected.ts @@ -0,0 +1,6 @@ +import { idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const client = new idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/input.ts new file mode 100644 index 000000000..a516d8d50 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/input.ts @@ -0,0 +1,6 @@ +import { idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/expected.ts new file mode 100644 index 000000000..025c22600 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/expected.ts @@ -0,0 +1,7 @@ +import { workflow, idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + await workflow.wait("ready"); + const client = new idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/input.ts new file mode 100644 index 000000000..dfd6409fb --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/input.ts @@ -0,0 +1,7 @@ +import { workflow } from "@tailor-platform/sdk/runtime"; + +export async function run() { + await workflow.wait("ready"); + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/for-of-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/for-of-idp/input.ts new file mode 100644 index 000000000..b6b5dc761 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/for-of-idp/input.ts @@ -0,0 +1,6 @@ +export async function run(users: unknown[]) { + for (const idp of users) { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); + } +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/function-expression-tailor/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/function-expression-tailor/input.ts new file mode 100644 index 000000000..408cb5785 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/function-expression-tailor/input.ts @@ -0,0 +1,4 @@ +export const run = function tailor() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +}; diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-alias-tailor/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-alias-tailor/input.ts new file mode 100644 index 000000000..fb4d7e05e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-alias-tailor/input.ts @@ -0,0 +1,5 @@ +namespace X { + import tailor = localTailor; + + export const client = new tailor.idp.Client({ namespace: "default" }); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-equals-tailor/input.cts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-equals-tailor/input.cts new file mode 100644 index 000000000..a96026f64 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-equals-tailor/input.cts @@ -0,0 +1,6 @@ +import tailor = require("./local-tailor"); + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/expected.ts new file mode 100644 index 000000000..f1ba1a4c7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/expected.ts @@ -0,0 +1,5 @@ +// @ts-nocheck +"use client"; +import { idp } from "@tailor-platform/sdk/runtime"; + +export const client = new idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/input.ts new file mode 100644 index 000000000..b9c615209 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/input.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +"use client"; + +export const client = new tailor.idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-idp/input.ts new file mode 100644 index 000000000..61dd769f6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-idp/input.ts @@ -0,0 +1,6 @@ +const idp = createLocalIdp(); + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-tailor/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-tailor/input.ts new file mode 100644 index 000000000..81d7567de --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-tailor/input.ts @@ -0,0 +1,6 @@ +const tailor = createLocalTailor(); + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/expected.ts new file mode 100644 index 000000000..38d7ec6fd --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/expected.ts @@ -0,0 +1,11 @@ +import { idp } from "@tailor-platform/sdk/runtime"; + +declare module "pkg" { + import { Existing } from "other"; + + export interface Thing { + value: Existing; + } +} + +export const client = new idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/input.ts new file mode 100644 index 000000000..6d3fb547b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/input.ts @@ -0,0 +1,9 @@ +declare module "pkg" { + import { Existing } from "other"; + + export interface Thing { + value: Existing; + } +} + +export const client = new tailor.idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/no-args/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/no-args/input.ts new file mode 100644 index 000000000..4f3a82a55 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/no-args/input.ts @@ -0,0 +1,4 @@ +export async function run() { + const client = new tailor.idp.Client(); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/string-literal/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/string-literal/input.ts new file mode 100644 index 000000000..f95c1c608 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/string-literal/input.ts @@ -0,0 +1 @@ +export const source = 'const client = new tailor.idp.Client({ namespace: "default" });'; diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/expected.ts new file mode 100644 index 000000000..025c22600 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/expected.ts @@ -0,0 +1,7 @@ +import { workflow, idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + await workflow.wait("ready"); + const client = new idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/input.ts new file mode 100644 index 000000000..0fbe749e5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/input.ts @@ -0,0 +1,7 @@ +import { workflow, type idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + await workflow.wait("ready"); + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/expected.ts new file mode 100644 index 000000000..2f61259b2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/expected.ts @@ -0,0 +1,6 @@ +import { idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const client = new idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/input.ts new file mode 100644 index 000000000..7ab633df1 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/input.ts @@ -0,0 +1,6 @@ +import { type idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/codemod.yaml b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/codemod.yaml new file mode 100644 index 000000000..64ea37424 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/runtime-subpath-namespace" +version: "1.0.0" +description: "Rewrite runtime namespace imports and aggregate file.deleteFile calls to v2 APIs" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/scripts/transform.ts new file mode 100644 index 000000000..791a79526 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/scripts/transform.ts @@ -0,0 +1,1225 @@ +import { parse, Lang } from "@ast-grep/napi"; +import { + collectBindingNames, + findImportStatements, + importBindings, + importSource, + importSpecNames, + isTypeOnlyImport, + localDeclarationNames, +} from "../../../../src/ast-grep-helpers"; +import type { LlmReviewFinding } from "../../../../src/types"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +interface RuntimeModule { + namespace: string; + source: string; + members: Record; +} + +interface FlatImport { + localName: string; + memberName: string; + typeOnly: boolean; +} + +interface ImportReplacement { + edit: Edit; + flatImports: FlatImport[]; + namespaceLocal: string; +} + +interface SelfNamespaceImport { + localName: string; + typeOnly: boolean; +} + +const RUNTIME_MODULES: RuntimeModule[] = [ + { + namespace: "iconv", + source: "@tailor-platform/sdk/runtime/iconv", + members: { + convert: "convert", + convertBuffer: "convertBuffer", + decode: "decode", + encode: "encode", + encodings: "encodings", + Iconv: "Iconv", + }, + }, + { + namespace: "secretmanager", + source: "@tailor-platform/sdk/runtime/secretmanager", + members: { getSecrets: "getSecrets", getSecret: "getSecret" }, + }, + { + namespace: "authconnection", + source: "@tailor-platform/sdk/runtime/authconnection", + members: { getConnectionToken: "getConnectionToken" }, + }, + { + namespace: "idp", + source: "@tailor-platform/sdk/runtime/idp", + members: { Client: "Client" }, + }, + { + namespace: "workflow", + source: "@tailor-platform/sdk/runtime/workflow", + members: { + triggerWorkflow: "triggerWorkflow", + resumeWorkflow: "resumeWorkflow", + triggerJobFunction: "triggerJobFunction", + wait: "wait", + resolve: "resolve", + }, + }, + { + namespace: "context", + source: "@tailor-platform/sdk/runtime/context", + members: { getInvoker: "getInvoker" }, + }, + { + namespace: "file", + source: "@tailor-platform/sdk/runtime/file", + members: { + upload: "upload", + download: "download", + downloadAsBase64: "downloadAsBase64", + delete: "delete", + deleteFile: "delete", + getMetadata: "getMetadata", + downloadStream: "downloadStream", + uploadStream: "uploadStream", + }, + }, + { + namespace: "aigateway", + source: "@tailor-platform/sdk/runtime/aigateway", + members: { get: "get" }, + }, +]; + +const MODULES_BY_SOURCE = new Map(RUNTIME_MODULES.map((mod) => [mod.source, mod])); +const AGGREGATE_RUNTIME_SOURCE = "@tailor-platform/sdk/runtime"; +const JSX_FILE_EXTENSIONS = new Set([".tsx", ".jsx"]); +const JS_FILE_EXTENSIONS = new Set([".js", ".mjs", ".cjs"]); + +function quickFilter(source: string): boolean { + return source.includes(AGGREGATE_RUNTIME_SOURCE); +} + +function sourceLang(filePath: string, source: string): Lang { + const lower = filePath.toLowerCase(); + const extension = lower.slice(lower.lastIndexOf(".")); + if (JSX_FILE_EXTENSIONS.has(extension)) return Lang.Tsx; + if (JS_FILE_EXTENSIONS.has(extension) && /<>|<\/>|<[A-Za-z][\w.$:-]/.test(source)) { + return Lang.Tsx; + } + return Lang.TypeScript; +} + +function importClause(importStmt: SgNode): SgNode | null { + return importStmt.children().find((child) => child.kind() === "import_clause") ?? null; +} + +function hasDefaultImport(importStmt: SgNode): boolean { + return ( + importClause(importStmt) + ?.children() + .some((child) => child.kind() === "identifier") ?? false + ); +} + +function namespaceImportName(importStmt: SgNode): string | null { + return ( + importClause(importStmt) + ?.children() + .find((child) => child.kind() === "namespace_import") + ?.children() + .find((child) => child.kind() === "identifier") + ?.text() ?? null + ); +} + +function formatImport( + source: string, + namedSpecs: string[], + typeOnly = false, + attributeText = "", +): string { + const importKeyword = typeOnly ? "import type" : "import"; + const named = namedSpecs.length > 0 ? `{ ${namedSpecs.join(", ")} }` : null; + const attributes = attributeText === "" ? "" : ` ${attributeText}`; + if (named) return `${importKeyword} ${named} from "${source}"${attributes};`; + return ""; +} + +function importAttributeText(importStmt: SgNode): string { + return ( + importStmt + .children() + .find((child) => child.kind() === "import_attribute") + ?.text() ?? "" + ); +} + +function replaceImportStatement(importStmt: SgNode, nextText: string, sourceText: string): Edit { + if (nextText !== "") return importStmt.replace(nextText); + + const range = importStmt.range(); + let endPos = range.end.index; + if (sourceText[endPos] === "\r" && sourceText[endPos + 1] === "\n") { + endPos += 2; + } else if (sourceText[endPos] === "\n") { + endPos += 1; + } + return { startPos: range.start.index, endPos, insertedText: "" }; +} + +function isInsideImportStatement(node: SgNode): boolean { + let current = node.parent(); + while (current) { + if (current.kind() === "import_statement") return true; + current = current.parent(); + } + return false; +} + +function isInsideExportSpecifier(node: SgNode): boolean { + let current = node.parent(); + while (current) { + if (current.kind() === "export_specifier") return true; + if (current.kind() === "export_statement") return false; + current = current.parent(); + } + return false; +} + +function isInsideTypeQuery(node: SgNode): boolean { + let current = node.parent(); + while (current) { + if (current.kind() === "type_query") return true; + if (current.kind() === "statement_block" || current.kind() === "program") return false; + current = current.parent(); + } + return false; +} + +function isJsxTagName(node: SgNode): boolean { + const parentKind = node.parent()?.kind(); + return ( + parentKind === "jsx_opening_element" || + parentKind === "jsx_self_closing_element" || + parentKind === "jsx_closing_element" + ); +} + +function sameNode(left: SgNode | null | undefined, right: SgNode): boolean { + if (!left) return false; + const leftRange = left.range(); + const rightRange = right.range(); + return ( + leftRange.start.index === rightRange.start.index && leftRange.end.index === rightRange.end.index + ); +} + +function typeParameterName(typeParameter: SgNode): SgNode | null { + return typeParameter.children().find((child) => child.kind() === "type_identifier") ?? null; +} + +function typeParametersDeclare(typeParameters: SgNode, name: string): boolean { + return typeParameters + .children() + .some( + (child) => child.kind() === "type_parameter" && typeParameterName(child)?.text() === name, + ); +} + +function isTypeParameterScoped(node: SgNode): boolean { + let current = node.parent(); + while (current) { + if (current.kind() === "type_parameter" && sameNode(typeParameterName(current), node)) { + return true; + } + + const typeParameters = current.children().find((child) => child.kind() === "type_parameters"); + if (typeParameters && typeParametersDeclare(typeParameters, node.text())) return true; + + current = current.parent(); + } + return false; +} + +function isNestedTypeMember(node: SgNode): boolean { + const parent = node.parent(); + if (parent?.kind() !== "nested_type_identifier") return false; + + const firstNamedChild = parent + .children() + .find((child) => child.kind() === "identifier" || child.kind() === "type_identifier"); + return !sameNode(firstNamedChild, node); +} + +function localTypeScopeDeclarationNames(root: SgNode): Set { + const names = new Set(); + for (const node of root.findAll({ + rule: { + any: [{ kind: "type_parameter" }, { kind: "infer_type" }, { kind: "mapped_type_clause" }], + }, + })) { + const name = node.children().find((child) => child.kind() === "type_identifier"); + if (name) names.add(name.text()); + } + return names; +} + +function hasExportSpecifierReference(root: SgNode, names: Set): boolean { + return root + .findAll({ rule: { kind: "export_specifier" } }) + .some((specifier) => + specifier + .children() + .some((child) => child.kind() === "identifier" && names.has(child.text())), + ); +} + +function hasNonTypeQueryTypeReference(root: SgNode, names: Set): boolean { + return root.findAll({ rule: { kind: "type_identifier" } }).some((node) => { + if (!names.has(node.text())) return false; + if (isInsideImportStatement(node)) return false; + if (isInsideExportSpecifier(node)) return false; + if (isTypeParameterScoped(node)) return false; + if (isNestedTypeMember(node)) return false; + return !isInsideTypeQuery(node); + }); +} + +function hasNamespaceTypeMemberReference(root: SgNode, namespaceLocal: string): boolean { + return root.findAll({ rule: { kind: "nested_type_identifier" } }).some((node) => { + const firstNamedChild = node + .children() + .find((child) => child.kind() === "identifier" || child.kind() === "type_identifier"); + return firstNamedChild?.text() === namespaceLocal; + }); +} + +function findExportStatements(root: SgNode): SgNode[] { + return root + .findAll({ rule: { kind: "export_statement" } }) + .filter((stmt) => stmt.parent()?.kind() === "program") + .toSorted((a, b) => a.range().start.index - b.range().start.index); +} + +function aggregateFileImportLocals(imports: SgNode[]): Set { + const locals = new Set(); + for (const importStmt of imports) { + if (importSource(importStmt) !== AGGREGATE_RUNTIME_SOURCE) continue; + for (const binding of importBindings(importStmt)) { + if (!binding.typeOnly && binding.importedName === "file") locals.add(binding.localName); + } + } + return locals; +} + +function isValueBindingLeafKind(kind: ReturnType): boolean { + return kind === "identifier" || kind === "shorthand_property_identifier_pattern"; +} + +function isValueBindingPatternKind(kind: ReturnType): boolean { + return ( + isValueBindingLeafKind(kind) || + kind === "object_pattern" || + kind === "array_pattern" || + kind === "rest_pattern" + ); +} + +function collectValueBindingNames(node: SgNode, names: Set, result: Set): void { + if (isValueBindingLeafKind(node.kind())) { + if (names.has(node.text())) result.add(node.text()); + return; + } + + for (const child of node.children()) { + if (child.kind() === "property_identifier") continue; + if (child.kind() === "=") break; + collectValueBindingNames(child, names, result); + } +} + +function valueBindingNames(node: SgNode, names: Set): Set { + const result = new Set(); + collectValueBindingNames(node, names, result); + return result; +} + +function directValueBindingNames(node: SgNode, names: Set): Set { + const result = new Set(); + for (const child of node.children()) { + if (child.kind() === "=") break; + if (isValueBindingPatternKind(child.kind())) { + collectValueBindingNames(child, names, result); + } + } + return result; +} + +function firstDeclaratorChild(node: SgNode): SgNode | null { + return node.children().find((child) => child.kind() !== "=") ?? null; +} + +type ShadowedRanges = Map>; + +function addShadowedRange(ranges: ShadowedRanges, name: string, scope: SgNode): void { + const range = scope.range(); + const existing = ranges.get(name) ?? []; + existing.push({ start: range.start.index, end: range.end.index }); + ranges.set(name, existing); +} + +function nearestValueScope(node: SgNode): SgNode { + let current = node.parent(); + while (current) { + const kind = current.kind(); + if ( + kind === "statement_block" || + kind === "program" || + kind === "switch_body" || + kind === "for_statement" || + kind === "for_in_statement" + ) { + return current; + } + current = current.parent(); + } + return node; +} + +function functionValueScope(node: SgNode): SgNode { + let current = node.parent(); + while (current) { + const kind = current.kind(); + if ( + kind === "function_declaration" || + kind === "function_expression" || + kind === "arrow_function" || + kind === "method_definition" || + kind === "program" + ) { + return current; + } + current = current.parent(); + } + return node; +} + +function variableValueScope(node: SgNode): SgNode { + const declaration = node.parent(); + return /^var\b/.test(declaration?.text().trimStart() ?? "") + ? functionValueScope(node) + : nearestValueScope(node); +} + +function parameterValueScope(node: SgNode): SgNode { + let current = node.parent(); + while (current) { + const kind = current.kind(); + if (kind === "formal_parameters") { + current = current.parent(); + continue; + } + if ( + kind === "function_declaration" || + kind === "function_expression" || + kind === "arrow_function" || + kind === "method_definition" + ) { + return current; + } + break; + } + return nearestValueScope(node); +} + +function buildShadowedRanges(root: SgNode, names: Set): ShadowedRanges { + const ranges: ShadowedRanges = new Map(); + + for (const decl of root.findAll({ rule: { kind: "variable_declarator" } })) { + if (isInsideImportStatement(decl)) continue; + const binding = firstDeclaratorChild(decl); + if (!binding) continue; + for (const name of valueBindingNames(binding, names)) { + addShadowedRange(ranges, name, variableValueScope(decl)); + } + } + + for (const decl of root.findAll({ + rule: { + any: [ + { kind: "function_declaration" }, + { kind: "class_declaration" }, + { kind: "enum_declaration" }, + ], + }, + })) { + const name = decl + .children() + .find((child) => child.kind() === "identifier" && names.has(child.text())); + if (name) addShadowedRange(ranges, name.text(), nearestValueScope(decl)); + } + + for (const expression of root.findAll({ + rule: { any: [{ kind: "function_expression" }, { kind: "class" }] }, + })) { + const name = expression + .children() + .find( + (child) => + (child.kind() === "identifier" || child.kind() === "type_identifier") && + names.has(child.text()), + ); + if (name) addShadowedRange(ranges, name.text(), expression); + } + + for (const param of root.findAll({ + rule: { any: [{ kind: "required_parameter" }, { kind: "optional_parameter" }] }, + })) { + for (const name of directValueBindingNames(param, names)) { + addShadowedRange(ranges, name, parameterValueScope(param)); + } + } + + for (const arrow of root.findAll({ rule: { kind: "arrow_function" } })) { + const children = arrow.children(); + const arrowIndex = children.findIndex((child) => child.kind() === "=>"); + if (arrowIndex === -1) continue; + for (const child of children.slice(0, arrowIndex)) { + if (child.kind() === "=") break; + if (!isValueBindingPatternKind(child.kind())) continue; + for (const name of valueBindingNames(child, names)) { + addShadowedRange(ranges, name, arrow); + } + } + } + + for (const catchClause of root.findAll({ rule: { kind: "catch_clause" } })) { + for (const name of directValueBindingNames(catchClause, names)) { + addShadowedRange(ranges, name, catchClause); + } + } + + for (const loop of root.findAll({ rule: { kind: "for_in_statement" } })) { + const children = loop.children(); + const keywordIndex = children.findIndex( + (child) => child.kind() === "in" || child.kind() === "of", + ); + if (keywordIndex === -1) continue; + for (const child of children.slice(0, keywordIndex)) { + for (const name of valueBindingNames(child, names)) { + addShadowedRange(ranges, name, loop); + } + } + } + + return ranges; +} + +function isShadowed(node: SgNode, ranges: ShadowedRanges): boolean { + const candidates = ranges.get(node.text()); + if (!candidates) return false; + const position = node.range().start.index; + return candidates.some((range) => position >= range.start && position < range.end); +} + +interface AggregateFileDeleteAccess { + node: SgNode; + property: SgNode; + computed: boolean; +} + +function isAggregateFileReceiver( + node: SgNode | null, + fileLocals: Set, + shadowedRanges: ShadowedRanges, +): node is SgNode { + return ( + node?.kind() === "identifier" && + fileLocals.has(node.text()) && + !isShadowed(node, shadowedRanges) + ); +} + +function aggregateFileDeleteAccesses(root: SgNode, imports: SgNode[]): AggregateFileDeleteAccess[] { + const fileLocals = aggregateFileImportLocals(imports); + if (fileLocals.size === 0) return []; + const shadowedRanges = buildShadowedRanges(root, fileLocals); + const accesses: AggregateFileDeleteAccess[] = []; + + for (const member of root.findAll({ rule: { kind: "member_expression" } })) { + const receiver = member.field("object"); + const property = member.children().find((child) => child.kind() === "property_identifier"); + if ( + isAggregateFileReceiver(receiver, fileLocals, shadowedRanges) && + property?.text() === "deleteFile" + ) { + accesses.push({ node: member, property, computed: false }); + } + } + + for (const subscript of root.findAll({ rule: { kind: "subscript_expression" } })) { + const receiver = subscript.field("object"); + const property = subscript.field("index"); + if ( + isAggregateFileReceiver(receiver, fileLocals, shadowedRanges) && + property?.kind() === "string" && + property.text().slice(1, -1) === "deleteFile" + ) { + accesses.push({ node: subscript, property, computed: true }); + } + } + + return accesses; +} + +function aggregateFileDeleteEdits(root: SgNode, imports: SgNode[]): Edit[] { + return aggregateFileDeleteAccesses(root, imports).map(({ property, computed }) => { + if (!computed) return property.replace("delete"); + const quote = property.text()[0] ?? '"'; + return property.replace(`${quote}delete${quote}`); + }); +} + +function declaratorSides(node: SgNode): { binding: SgNode; value: SgNode } | null { + const children = node.children(); + const equalsIndex = children.findIndex((child) => child.kind() === "="); + if (equalsIndex === -1) return null; + const binding = children.slice(0, equalsIndex).find((child) => child.kind() !== "comment"); + const value = children.slice(equalsIndex + 1).find((child) => child.kind() !== "comment"); + return binding && value ? { binding, value } : null; +} + +function aggregateFileDeleteDestructures(root: SgNode, imports: SgNode[]): SgNode[] { + const fileLocals = aggregateFileImportLocals(imports); + if (fileLocals.size === 0) return []; + const shadowedRanges = buildShadowedRanges(root, fileLocals); + + return root.findAll({ rule: { kind: "variable_declarator" } }).filter((declarator) => { + const sides = declaratorSides(declarator); + if ( + !sides || + sides.binding.kind() !== "object_pattern" || + !isAggregateFileReceiver(sides.value, fileLocals, shadowedRanges) + ) { + return false; + } + return sides.binding + .findAll({ + rule: { + any: [{ kind: "property_identifier" }, { kind: "shorthand_property_identifier_pattern" }], + }, + }) + .some((property) => property.text() === "deleteFile"); + }); +} + +function aggregateFileDeleteFindingNodes(root: SgNode, imports: SgNode[]): SgNode[] { + return [ + ...aggregateFileDeleteAccesses(root, imports).map((access) => access.node), + ...aggregateFileDeleteDestructures(root, imports), + ]; +} + +function usedNames(root: SgNode, imports: SgNode[], removedNames: Set): Set { + const names = localDeclarationNames(root); + for (const importStmt of imports) { + for (const binding of importBindings(importStmt)) { + if (!removedNames.has(binding.localName)) names.add(binding.localName); + } + } + return names; +} + +function uniqueNamespaceLocal( + mod: RuntimeModule, + root: SgNode, + imports: SgNode[], + removedNames: Set, +): string { + const names = usedNames(root, imports, removedNames); + if (!names.has(mod.namespace)) return mod.namespace; + + const base = `${mod.namespace}Runtime`; + if (!names.has(base)) return base; + + for (let i = 2; ; i++) { + const candidate = `${base}${i}`; + if (!names.has(candidate)) return candidate; + } +} + +function selfNamespaceSpec(mod: RuntimeModule, localName: string): string { + return localName === mod.namespace ? mod.namespace : `${mod.namespace} as ${localName}`; +} + +function typeOnlySelfNamespaceSpec(mod: RuntimeModule, localName: string): string { + return `type ${selfNamespaceSpec(mod, localName)}`; +} + +function existingSelfNamespaceImport( + importStmt: SgNode, + mod: RuntimeModule, + statementTypeOnly: boolean, +): SelfNamespaceImport | null { + for (const spec of importStmt.findAll({ rule: { kind: "import_specifier" } })) { + const names = importSpecNames(spec); + if (!names || names.importedName !== mod.namespace) continue; + return { localName: names.localName, typeOnly: statementTypeOnly || names.typeOnly }; + } + return null; +} + +function flatImportsFor(importStmt: SgNode, mod: RuntimeModule): FlatImport[] { + const statementTypeOnly = isTypeOnlyImport(importStmt); + const flatImports: FlatImport[] = []; + for (const spec of importStmt.findAll({ rule: { kind: "import_specifier" } })) { + const names = importSpecNames(spec); + if (!names) continue; + const memberName = mod.members[names.importedName]; + if (!memberName) continue; + flatImports.push({ + localName: names.localName, + memberName, + typeOnly: statementTypeOnly || names.typeOnly, + }); + } + return flatImports; +} + +function plannedValueNamespaceLocal( + importStmt: SgNode, + mod: RuntimeModule, + root: SgNode, + imports: SgNode[], +): string | null { + const source = importSource(importStmt); + if (!source) return null; + + for (const candidate of imports) { + if (sameNode(candidate, importStmt)) continue; + if (importSource(candidate) !== source || isTypeOnlyImport(candidate)) continue; + + const namespaceName = namespaceImportName(candidate); + if (namespaceName) return namespaceName; + + const existingSelf = existingSelfNamespaceImport(candidate, mod, false); + if (existingSelf && !existingSelf.typeOnly) return existingSelf.localName; + + const valueFlatImports = flatImportsFor(candidate, mod).filter((binding) => !binding.typeOnly); + if (valueFlatImports.length === 0) continue; + + const removedNames = new Set(valueFlatImports.map((binding) => binding.localName)); + const declaredNames = new Set([ + ...localDeclarationNames(root), + ...localTypeScopeDeclarationNames(root), + ]); + if (valueFlatImports.some((binding) => declaredNames.has(binding.localName))) continue; + if (hasExportSpecifierReference(root, removedNames)) continue; + + return uniqueNamespaceLocal(mod, root, imports, removedNames); + } + + return null; +} + +function buildImportReplacement( + importStmt: SgNode, + mod: RuntimeModule, + root: SgNode, + imports: SgNode[], + sourceText: string, + emittedNamespaceSpecifiers: Set, +): ImportReplacement | null { + const source = importSource(importStmt); + if (!source) return null; + + const statementTypeOnly = isTypeOnlyImport(importStmt); + const attributes = importAttributeText(importStmt); + const namespaceName = namespaceImportName(importStmt); + if (namespaceName) { + if (hasNamespaceTypeMemberReference(root, namespaceName)) return null; + const edit = importStmt.replace( + formatImport(source, [selfNamespaceSpec(mod, namespaceName)], statementTypeOnly, attributes), + ); + return { + edit, + flatImports: [], + namespaceLocal: namespaceName, + }; + } + + if (hasDefaultImport(importStmt)) return null; + + const existingSelf = existingSelfNamespaceImport(importStmt, mod, statementTypeOnly); + const flatImports: FlatImport[] = []; + const keptSpecs: string[] = []; + + for (const spec of importStmt.findAll({ rule: { kind: "import_specifier" } })) { + const names = importSpecNames(spec); + if (!names) continue; + + const memberName = mod.members[names.importedName]; + if (memberName) { + flatImports.push({ + localName: names.localName, + memberName, + typeOnly: statementTypeOnly || names.typeOnly, + }); + continue; + } + + keptSpecs.push(spec.text()); + } + + if (flatImports.length === 0) return null; + + const removedNames = new Set(flatImports.map((binding) => binding.localName)); + const declaredNames = new Set([ + ...localDeclarationNames(root), + ...localTypeScopeDeclarationNames(root), + ]); + if (flatImports.some((binding) => declaredNames.has(binding.localName))) return null; + if (hasExportSpecifierReference(root, removedNames)) return null; + if (hasNonTypeQueryTypeReference(root, removedNames)) return null; + + const flatImportsAreTypeOnly = flatImports.every((binding) => binding.typeOnly); + const canUseExistingSelf = + existingSelf != null && (!existingSelf.typeOnly || flatImportsAreTypeOnly); + const plannedValueLocal = flatImportsAreTypeOnly + ? plannedValueNamespaceLocal(importStmt, mod, root, imports) + : null; + const namespaceLocal = + plannedValueLocal ?? + (canUseExistingSelf + ? existingSelf.localName + : uniqueNamespaceLocal(mod, root, imports, removedNames)); + const namespaceSpecifierKey = [ + source, + namespaceLocal, + flatImportsAreTypeOnly ? "type" : "value", + ].join("\0"); + const namespaceAlreadyEmitted = emittedNamespaceSpecifiers.has(namespaceSpecifierKey); + const needsNamespaceSpecifier = + plannedValueLocal == null && !canUseExistingSelf && !namespaceAlreadyEmitted; + if (needsNamespaceSpecifier) emittedNamespaceSpecifiers.add(namespaceSpecifierKey); + const namespaceSpecifier = + flatImportsAreTypeOnly && !statementTypeOnly + ? typeOnlySelfNamespaceSpec(mod, namespaceLocal) + : selfNamespaceSpec(mod, namespaceLocal); + const nextNamedSpecs = needsNamespaceSpecifier ? [namespaceSpecifier, ...keptSpecs] : keptSpecs; + + return { + edit: replaceImportStatement( + importStmt, + formatImport(source, nextNamedSpecs, statementTypeOnly, attributes), + sourceText, + ), + flatImports, + namespaceLocal, + }; +} + +function referenceEdits(root: SgNode, replacements: ImportReplacement[]): Edit[] { + const byLocalName = new Map< + string, + { namespaceLocal: string; memberName: string; typeOnly: boolean } + >(); + for (const replacement of replacements) { + for (const binding of replacement.flatImports) { + byLocalName.set(binding.localName, { + namespaceLocal: replacement.namespaceLocal, + memberName: binding.memberName, + typeOnly: binding.typeOnly, + }); + } + } + + const edits: Edit[] = []; + const replacementFor = (name: string): string | null => { + const binding = byLocalName.get(name); + return binding ? `${binding.namespaceLocal}.${binding.memberName}` : null; + }; + + for (const node of root.findAll({ rule: { kind: "identifier" } })) { + if (isInsideImportStatement(node)) continue; + if (isInsideExportSpecifier(node)) continue; + if (isJsxTagName(node)) continue; + if (byLocalName.get(node.text())?.typeOnly && !isInsideTypeQuery(node)) continue; + const replacement = replacementFor(node.text()); + if (!replacement) continue; + edits.push(node.replace(replacement)); + } + + for (const node of root.findAll({ rule: { kind: "type_identifier" } })) { + if (isInsideImportStatement(node)) continue; + if (isInsideExportSpecifier(node)) continue; + if (!isInsideTypeQuery(node)) continue; + if (isTypeParameterScoped(node)) continue; + if (isNestedTypeMember(node)) continue; + const replacement = replacementFor(node.text()); + if (!replacement) continue; + edits.push(node.replace(replacement)); + } + + for (const node of root.findAll({ rule: { kind: "shorthand_property_identifier" } })) { + if (byLocalName.get(node.text())?.typeOnly) continue; + const replacement = replacementFor(node.text()); + if (!replacement) continue; + edits.push(node.replace(`${node.text()}: ${replacement}`)); + } + return edits; +} + +/** + * Rewrite v1 runtime subpath imports to the v2 namespace object exports. + * @param source - File contents + * @param filePath - Absolute path to the file + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, filePath: string): string | null { + if (!quickFilter(source)) return null; + + const root = parse(sourceLang(filePath, source), source).root(); + const imports = findImportStatements(root); + const replacements: ImportReplacement[] = []; + const emittedNamespaceSpecifiers = new Set(); + + for (const importStmt of imports) { + const sourceName = importSource(importStmt); + if (!sourceName) continue; + const mod = MODULES_BY_SOURCE.get(sourceName); + if (!mod) continue; + + const replacement = buildImportReplacement( + importStmt, + mod, + root, + imports, + source, + emittedNamespaceSpecifiers, + ); + if (replacement) replacements.push(replacement); + } + + const aggregateEdits = aggregateFileDeleteEdits(root, imports); + if (replacements.length === 0 && aggregateEdits.length === 0) return null; + + const edits = [ + ...replacements.map((replacement) => replacement.edit), + ...referenceEdits(root, replacements), + ...aggregateEdits, + ]; + const result = root.commitEdits(edits); + return result === source ? null : result; +} + +function hasRemovedFlatSpecifier(node: SgNode, mod: RuntimeModule): boolean { + return node + .findAll({ rule: { any: [{ kind: "import_specifier" }, { kind: "export_specifier" }] } }) + .some((spec) => { + const names = importSpecNames(spec); + return names != null && mod.members[names.importedName] != null; + }); +} + +function isExportStar(node: SgNode): boolean { + if (node.children().some((child) => child.kind() === "*")) return true; + const namespaceExport = node.children().find((child) => child.kind() === "namespace_export"); + return namespaceExport?.children().some((child) => child.kind() === "*") ?? false; +} + +function literalModuleSource(node: SgNode): string | null { + if (node.kind() === "string") return importSource(node); + if (node.kind() !== "template_string") return null; + if (node.children().some((child) => child.kind() === "template_substitution")) return null; + + const text = node.text(); + return text.startsWith("`") && text.endsWith("`") ? text.slice(1, -1) : null; +} + +function isSourceScopeNode(node: SgNode): boolean { + const kind = node.kind(); + return ( + kind === "program" || + kind === "statement_block" || + kind === "function_declaration" || + kind === "arrow_function" || + kind === "method_definition" + ); +} + +function nearestSourceScope(node: SgNode): SgNode | null { + let current = node.parent(); + while (current) { + if (isSourceScopeNode(current)) return current; + current = current.parent(); + } + return null; +} + +function sameRange(left: SgNode | null, right: SgNode): boolean { + if (!left) return false; + const leftRange = left.range(); + const rightRange = right.range(); + return ( + leftRange.start.index === rightRange.start.index && leftRange.end.index === rightRange.end.index + ); +} + +function isConstVariableDeclarator(node: SgNode): boolean { + return ( + node + .parent() + ?.children() + .some((child) => child.kind() === "const") ?? false + ); +} + +function sourceConstInitializerContent(node: SgNode): string | null { + const directValue = literalModuleSource(node); + if (directValue != null) return directValue; + if ( + node.kind() !== "as_expression" && + node.kind() !== "satisfies_expression" && + node.kind() !== "parenthesized_expression" + ) { + return null; + } + for (const child of node.children()) { + const childValue = sourceConstInitializerContent(child); + if (childValue != null) return childValue; + } + return null; +} + +function sourceConstVariableDeclaratorContent(node: SgNode, name: string): string | null { + if (!isConstVariableDeclarator(node)) return null; + const names = new Set(); + collectBindingNames(node, names); + if (!names.has(name)) return null; + + const initializer = node + .children() + .findLast((child) => sourceConstInitializerContent(child) != null); + return initializer == null ? null : sourceConstInitializerContent(initializer); +} + +function bindingNames(node: SgNode): Set { + const names = new Set(); + collectBindingNames(node, names); + return names; +} + +function sourceStringVariableInScope( + scope: SgNode, + name: string, + before: number, +): string | null | undefined { + const bindings = scope + .findAll({ + rule: { + any: [ + { kind: "variable_declarator" }, + { kind: "required_parameter" }, + { kind: "optional_parameter" }, + { kind: "catch_clause" }, + ], + }, + }) + .filter( + (node) => node.range().start.index < before && sameRange(nearestSourceScope(node), scope), + ) + .toSorted((a, b) => b.range().start.index - a.range().start.index); + + for (const binding of bindings) { + if (!bindingNames(binding).has(name)) continue; + return binding.kind() === "variable_declarator" + ? sourceConstVariableDeclaratorContent(binding, name) + : null; + } + + return undefined; +} + +function sourceScopedStringVariableContent(identifier: SgNode): string | null { + const name = identifier.text(); + const before = identifier.range().start.index; + let current = identifier.parent(); + while (current) { + if (isSourceScopeNode(current)) { + const value = sourceStringVariableInScope(current, name, before); + if (value !== undefined) return value; + } + current = current.parent(); + } + return null; +} + +function isDynamicImportCall(node: SgNode): boolean { + return ( + node.kind() === "call_expression" && node.children().some((child) => child.kind() === "import") + ); +} + +function isRequireCall(node: SgNode): boolean { + return ( + node.kind() === "call_expression" && + node.children().some((child) => child.kind() === "identifier" && child.text() === "require") + ); +} + +function isArgumentSyntaxNode(node: SgNode): boolean { + const kind = node.kind(); + return kind === "(" || kind === ")" || kind === "," || kind === "comment"; +} + +function firstCallArgument(callExpression: SgNode): SgNode | null { + const args = callExpression.children().find((child) => child.kind() === "arguments"); + return args?.children().find((child) => !isArgumentSyntaxNode(child)) ?? null; +} + +function moduleCallSourceName(callExpression: SgNode): string | null { + const sourceArg = firstCallArgument(callExpression); + if (!sourceArg) return null; + + const sourceName = literalModuleSource(sourceArg); + if (sourceName != null) return sourceName; + + return sourceArg.kind() === "identifier" ? sourceScopedStringVariableContent(sourceArg) : null; +} + +function dynamicImportSourceName(callExpression: SgNode): string | null { + return moduleCallSourceName(callExpression); +} + +function dynamicImportExcerptNode(callExpression: SgNode): SgNode { + let current = callExpression; + while (current.parent()) { + const parent = current.parent(); + if (!parent) break; + if ( + parent.kind() !== "await_expression" && + parent.kind() !== "parenthesized_expression" && + parent.kind() !== "member_expression" + ) { + break; + } + current = parent; + } + return current; +} + +function dynamicRuntimeImportFindings(root: SgNode, relativePath: string): LlmReviewFinding[] { + const findings: LlmReviewFinding[] = []; + for (const callExpression of root.findAll({ rule: { kind: "call_expression" } })) { + if (!isDynamicImportCall(callExpression)) continue; + const sourceName = dynamicImportSourceName(callExpression); + if (!sourceName || !MODULES_BY_SOURCE.has(sourceName)) continue; + const excerptNode = dynamicImportExcerptNode(callExpression); + findings.push({ + file: relativePath, + line: excerptNode.range().start.line + 1, + message: "Dynamic runtime subpath import may still access a removed flat value export.", + excerpt: excerptNode.text().trim(), + }); + } + return findings; +} + +function runtimeRequireFindings(root: SgNode, relativePath: string): LlmReviewFinding[] { + const findings: LlmReviewFinding[] = []; + for (const callExpression of root.findAll({ rule: { kind: "call_expression" } })) { + if (isInsideImportStatement(callExpression)) continue; + if (!isRequireCall(callExpression)) continue; + const sourceName = moduleCallSourceName(callExpression); + if (!sourceName || !MODULES_BY_SOURCE.has(sourceName)) continue; + findings.push({ + file: relativePath, + line: callExpression.range().start.line + 1, + message: "CommonJS runtime subpath require may still access a removed flat value export.", + excerpt: callExpression.text().trim(), + }); + } + return findings; +} + +function isImportRequireStatement(importStmt: SgNode): boolean { + return importStmt.children().some((child) => child.kind() === "import_require_clause"); +} + +export function reviewFindings( + source: string, + filePath: string, + relativePath: string, +): LlmReviewFinding[] { + if (!quickFilter(source)) return []; + + const root = parse(sourceLang(filePath, source), source).root(); + const imports = findImportStatements(root); + const findings: LlmReviewFinding[] = []; + findings.push(...dynamicRuntimeImportFindings(root, relativePath)); + findings.push(...runtimeRequireFindings(root, relativePath)); + + for (const access of aggregateFileDeleteFindingNodes(root, imports)) { + findings.push({ + file: relativePath, + line: access.range().start.line + 1, + message: "Aggregate runtime file namespace still uses the removed deleteFile alias.", + excerpt: access.text().trim(), + }); + } + + for (const importStmt of imports) { + const sourceName = importSource(importStmt); + if (!sourceName) continue; + const mod = MODULES_BY_SOURCE.get(sourceName); + if (!mod) continue; + + const hasImportRequire = isImportRequireStatement(importStmt); + const hasRemovedDefaultImport = hasDefaultImport(importStmt); + const hasNamespaceImport = namespaceImportName(importStmt) != null; + const hasRemovedFlatImport = hasRemovedFlatSpecifier(importStmt, mod); + if ( + !hasImportRequire && + !hasRemovedDefaultImport && + !hasNamespaceImport && + !hasRemovedFlatImport + ) { + continue; + } + + findings.push({ + file: relativePath, + line: importStmt.range().start.line + 1, + message: hasImportRequire + ? "TypeScript runtime subpath import-equals may still access a removed flat value export." + : "Runtime subpath import still uses a removed default, namespace-star, or flat value import.", + excerpt: importStmt.text().trim(), + }); + } + + for (const exportStmt of findExportStatements(root)) { + const sourceName = importSource(exportStmt); + if (!sourceName) continue; + const mod = MODULES_BY_SOURCE.get(sourceName); + if (!mod || (!hasRemovedFlatSpecifier(exportStmt, mod) && !isExportStar(exportStmt))) continue; + + findings.push({ + file: relativePath, + line: exportStmt.range().start.line + 1, + message: "Runtime subpath re-export still uses a removed flat value export.", + excerpt: exportStmt.text().trim(), + }); + } + + return findings; +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aggregate-file-delete/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aggregate-file-delete/expected.ts new file mode 100644 index 000000000..0a4d0b712 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aggregate-file-delete/expected.ts @@ -0,0 +1,23 @@ +import { file as runtimeFile } from "@tailor-platform/sdk/runtime"; + +await runtimeFile.delete("ns", "Doc", "blob", "rec-1"); +await runtimeFile["delete"]("ns", "Doc", "blob", "rec-2"); + +function remove(runtimeFile: { deleteFile(): void }) { + runtimeFile.deleteFile(); +} + +const callback = function runtimeFile() { + runtimeFile.deleteFile(); +}; + +const RuntimeFile = class runtimeFile { + remove() { + runtimeFile.deleteFile(); + } +}; + +const localFile = { + deleteFile() {}, +}; +localFile.deleteFile(); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aggregate-file-delete/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aggregate-file-delete/input.ts new file mode 100644 index 000000000..9b5a6676f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aggregate-file-delete/input.ts @@ -0,0 +1,23 @@ +import { file as runtimeFile } from "@tailor-platform/sdk/runtime"; + +await runtimeFile.deleteFile("ns", "Doc", "blob", "rec-1"); +await runtimeFile["deleteFile"]("ns", "Doc", "blob", "rec-2"); + +function remove(runtimeFile: { deleteFile(): void }) { + runtimeFile.deleteFile(); +} + +const callback = function runtimeFile() { + runtimeFile.deleteFile(); +}; + +const RuntimeFile = class runtimeFile { + remove() { + runtimeFile.deleteFile(); + } +}; + +const localFile = { + deleteFile() {}, +}; +localFile.deleteFile(); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aliased-flat-import/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aliased-flat-import/expected.ts new file mode 100644 index 000000000..c91d25944 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aliased-flat-import/expected.ts @@ -0,0 +1,4 @@ +import { idp } from "@tailor-platform/sdk/runtime/idp"; + +const ClientRef = idp.Client; +const client = new idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aliased-flat-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aliased-flat-import/input.ts new file mode 100644 index 000000000..6ab1b4d58 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/aliased-flat-import/input.ts @@ -0,0 +1,4 @@ +import { Client as IdpClient } from "@tailor-platform/sdk/runtime/idp"; + +const ClientRef = IdpClient; +const client = new IdpClient({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-file-import/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-file-import/expected.ts new file mode 100644 index 000000000..707446513 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-file-import/expected.ts @@ -0,0 +1,3 @@ +import { file } from "@tailor-platform/sdk/runtime/file"; + +await file.delete("ns", "Doc", "blob", "rec-1"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-file-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-file-import/input.ts new file mode 100644 index 000000000..cfa8d789b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-file-import/input.ts @@ -0,0 +1,3 @@ +import { deleteFile } from "@tailor-platform/sdk/runtime/file"; + +await deleteFile("ns", "Doc", "blob", "rec-1"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-keyword-import/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-keyword-import/expected.ts new file mode 100644 index 000000000..707446513 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-keyword-import/expected.ts @@ -0,0 +1,3 @@ +import { file } from "@tailor-platform/sdk/runtime/file"; + +await file.delete("ns", "Doc", "blob", "rec-1"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-keyword-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-keyword-import/input.ts new file mode 100644 index 000000000..32580750c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/delete-keyword-import/input.ts @@ -0,0 +1,3 @@ +import { delete as del } from "@tailor-platform/sdk/runtime/file"; + +await del("ns", "Doc", "blob", "rec-1"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/export-specifier/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/export-specifier/input.ts new file mode 100644 index 000000000..e9af0607b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/export-specifier/input.ts @@ -0,0 +1,3 @@ +import { get } from "@tailor-platform/sdk/runtime/aigateway"; + +export { get }; diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/flat-named-import/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/flat-named-import/expected.ts new file mode 100644 index 000000000..84b81357e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/flat-named-import/expected.ts @@ -0,0 +1,3 @@ +import { aigateway, type GetAIGatewayResult } from "@tailor-platform/sdk/runtime/aigateway"; + +const result: Promise = aigateway.get("main"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/flat-named-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/flat-named-import/input.ts new file mode 100644 index 000000000..eb3c718f0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/flat-named-import/input.ts @@ -0,0 +1,3 @@ +import { get, type GetAIGatewayResult } from "@tailor-platform/sdk/runtime/aigateway"; + +const result: Promise = get("main"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/flat-type-reference/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/flat-type-reference/input.ts new file mode 100644 index 000000000..5c5126a77 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/flat-type-reference/input.ts @@ -0,0 +1,4 @@ +import { Client as IdpClient } from "@tailor-platform/sdk/runtime/idp"; + +let client: IdpClient; +client = new IdpClient({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/import-attributes/expected.cts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/import-attributes/expected.cts new file mode 100644 index 000000000..4765c2698 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/import-attributes/expected.cts @@ -0,0 +1,5 @@ +import type { Client } from "@tailor-platform/sdk/runtime/idp" with { "resolution-mode": "import" }; +import { aigateway } from "@tailor-platform/sdk/runtime/aigateway" assert { type: "json" }; + +type ClientRef = Client; +const gateway = aigateway.get("main"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/import-attributes/input.cts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/import-attributes/input.cts new file mode 100644 index 000000000..8d8293b85 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/import-attributes/input.cts @@ -0,0 +1,5 @@ +import type { Client } from "@tailor-platform/sdk/runtime/idp" with { "resolution-mode": "import" }; +import { get } from "@tailor-platform/sdk/runtime/aigateway" assert { type: "json" }; + +type ClientRef = Client; +const gateway = get("main"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/inline-type-flat-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/inline-type-flat-import/input.ts new file mode 100644 index 000000000..9fd6d1a87 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/inline-type-flat-import/input.ts @@ -0,0 +1,3 @@ +import { type Client } from "@tailor-platform/sdk/runtime/idp"; + +type ClientRef = Client; diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/jsx-tag-name/expected.tsx b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/jsx-tag-name/expected.tsx new file mode 100644 index 000000000..57d993ff2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/jsx-tag-name/expected.tsx @@ -0,0 +1,8 @@ +import { aigateway } from "@tailor-platform/sdk/runtime/aigateway"; + +const gateway = aigateway.get("main"); +const element = ( + + + +); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/jsx-tag-name/input.tsx b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/jsx-tag-name/input.tsx new file mode 100644 index 000000000..7a04a382c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/jsx-tag-name/input.tsx @@ -0,0 +1,8 @@ +import { get } from "@tailor-platform/sdk/runtime/aigateway"; + +const gateway = get("main"); +const element = ( + + + +); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/namespace-import-type/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/namespace-import-type/input.ts new file mode 100644 index 000000000..44b654299 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/namespace-import-type/input.ts @@ -0,0 +1,3 @@ +import * as idp from "@tailor-platform/sdk/runtime/idp"; + +const config: idp.ClientConfig = { namespace: "default" }; diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/namespace-import/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/namespace-import/expected.ts new file mode 100644 index 000000000..f31e290a8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/namespace-import/expected.ts @@ -0,0 +1,3 @@ +import { iconv as runtimeIconv } from "@tailor-platform/sdk/runtime/iconv"; + +const out = runtimeIconv.convert("a", "UTF-8", "Shift_JIS"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/namespace-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/namespace-import/input.ts new file mode 100644 index 000000000..9f9216f63 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/namespace-import/input.ts @@ -0,0 +1,3 @@ +import * as runtimeIconv from "@tailor-platform/sdk/runtime/iconv"; + +const out = runtimeIconv.convert("a", "UTF-8", "Shift_JIS"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/repeated-flat-imports/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/repeated-flat-imports/expected.ts new file mode 100644 index 000000000..9c4fb137b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/repeated-flat-imports/expected.ts @@ -0,0 +1,4 @@ +import { aigateway } from "@tailor-platform/sdk/runtime/aigateway"; + +const first = await aigateway.get("main"); +const second = await aigateway.get("other"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/repeated-flat-imports/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/repeated-flat-imports/input.ts new file mode 100644 index 000000000..18643314c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/repeated-flat-imports/input.ts @@ -0,0 +1,5 @@ +import { get } from "@tailor-platform/sdk/runtime/aigateway"; +import { get as getAgain } from "@tailor-platform/sdk/runtime/aigateway"; + +const first = await get("main"); +const second = await getAgain("other"); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/scoped-type-identifiers/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/scoped-type-identifiers/input.ts new file mode 100644 index 000000000..8220e4889 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/scoped-type-identifiers/input.ts @@ -0,0 +1,9 @@ +import type { External } from "./external"; +import { Client } from "@tailor-platform/sdk/runtime/idp"; + +type Wrapper = { + external: External.Client; + runtime: import("@tailor-platform/sdk/runtime/idp").Client; +}; + +type RuntimeClient = Client; diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/shorthand-property/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/shorthand-property/expected.ts new file mode 100644 index 000000000..50af99ed6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/shorthand-property/expected.ts @@ -0,0 +1,3 @@ +import { aigateway } from "@tailor-platform/sdk/runtime/aigateway"; + +export const helpers = { get: aigateway.get }; diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/shorthand-property/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/shorthand-property/input.ts new file mode 100644 index 000000000..58d8d3db4 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/shorthand-property/input.ts @@ -0,0 +1,3 @@ +import { get } from "@tailor-platform/sdk/runtime/aigateway"; + +export const helpers = { get }; diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-type-value-imports/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-type-value-imports/expected.ts new file mode 100644 index 000000000..263144644 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-type-value-imports/expected.ts @@ -0,0 +1,6 @@ +import type { Client, ClientConfig } from "@tailor-platform/sdk/runtime/idp"; +import { idp } from "@tailor-platform/sdk/runtime/idp"; + +const config: ClientConfig = { namespace: "default" }; +type ClientRef = Client; +const client = new idp.Client(config); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-type-value-imports/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-type-value-imports/input.ts new file mode 100644 index 000000000..e7874ec0e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-type-value-imports/input.ts @@ -0,0 +1,6 @@ +import type { Client, ClientConfig } from "@tailor-platform/sdk/runtime/idp"; +import { Client as IdpClient } from "@tailor-platform/sdk/runtime/idp"; + +const config: ClientConfig = { namespace: "default" }; +type ClientRef = Client; +const client = new IdpClient(config); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-value-type-imports/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-value-type-imports/expected.ts new file mode 100644 index 000000000..ef5d6d49b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-value-type-imports/expected.ts @@ -0,0 +1,5 @@ +import { idp } from "@tailor-platform/sdk/runtime/idp"; +import type { Client } from "@tailor-platform/sdk/runtime/idp"; + +type ClientRef = Client; +const client = new idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-value-type-imports/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-value-type-imports/input.ts new file mode 100644 index 000000000..d7ee5f78f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/split-value-type-imports/input.ts @@ -0,0 +1,5 @@ +import { Client as IdpClient } from "@tailor-platform/sdk/runtime/idp"; +import type { Client } from "@tailor-platform/sdk/runtime/idp"; + +type ClientRef = Client; +const client = new IdpClient({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-flat-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-flat-import/input.ts new file mode 100644 index 000000000..eb967a6b6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-flat-import/input.ts @@ -0,0 +1,4 @@ +import type { Client, ClientConfig } from "@tailor-platform/sdk/runtime/idp"; + +const config: ClientConfig = { namespace: "default" }; +type ClientRef = Client; diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-import/input.ts new file mode 100644 index 000000000..726a939c5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-import/input.ts @@ -0,0 +1,3 @@ +import type { ClientConfig } from "@tailor-platform/sdk/runtime/idp"; + +const config: ClientConfig = { namespace: "default" }; diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-typeof/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-typeof/expected.ts new file mode 100644 index 000000000..2509f9174 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-typeof/expected.ts @@ -0,0 +1,3 @@ +import type { idp } from "@tailor-platform/sdk/runtime/idp"; + +type ClientConstructor = typeof idp.Client; diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-typeof/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-typeof/input.ts new file mode 100644 index 000000000..ba9d6c173 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-only-typeof/input.ts @@ -0,0 +1,3 @@ +import type { Client } from "@tailor-platform/sdk/runtime/idp"; + +type ClientConstructor = typeof Client; diff --git a/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-scope-shadow/input.ts b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-scope-shadow/input.ts new file mode 100644 index 000000000..2829e98da --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-subpath-namespace/tests/type-scope-shadow/input.ts @@ -0,0 +1,7 @@ +import { Client } from "@tailor-platform/sdk/runtime/idp"; + +type Wrapper = Client; +type Unwrap = T extends Promise ? Client : never; +type MapClient = { [Client in Keys]: Client }; + +const client = new Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/codemod.yaml b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/codemod.yaml index 07ab7bbff..45ebf71b5 100644 --- a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/codemod.yaml +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/codemod.yaml @@ -1,6 +1,6 @@ name: "@tailor-platform/sdk-skills-shim" version: "1.0.0" -description: "Replace deprecated `tailor-sdk-skills` binary with `tailor-sdk skills install`" +description: "Replace deprecated `tailor-sdk-skills` binary with `tailor skills add`" engine: jssg language: text since: "1.0.0" diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/scripts/transform.ts index bdac5661a..6c40f1742 100644 --- a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/scripts/transform.ts +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/scripts/transform.ts @@ -7,7 +7,7 @@ import * as path from "pathe"; // to the new subcommand. `[ \t]+` (not `\s+`) prevents the optional-install // alternative from greedily reaching across newlines into the next command. const SHIM_PATTERN = /\btailor-sdk-skills(?:@[^\s'"`]+)?(?:[ \t]+install)?\b(?!-)/g; -const REPLACEMENT = "tailor-sdk skills install"; +const REPLACEMENT = "tailor skills add"; function replaceShim(value: string): string { return value.replace(SHIM_PATTERN, REPLACEMENT); @@ -48,10 +48,10 @@ function transformPackageJson(source: string): string | null { } /** - * Replace `tailor-sdk-skills` invocations with `tailor-sdk skills install`. + * Replace `tailor-sdk-skills` invocations with `tailor skills add`. * * The standalone `tailor-sdk-skills` binary is removed in v2; users must call - * the subcommand on the main `tailor-sdk` CLI instead. + * the subcommand on the main `tailor` CLI instead. * @param source - File contents * @param filePath - Absolute path to the file (used to dispatch package.json vs text) * @returns Transformed source or null when nothing matched. diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-package-json/expected.json b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-package-json/expected.json index fddd67a36..d37fb88d9 100644 --- a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-package-json/expected.json +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-package-json/expected.json @@ -1,7 +1,7 @@ { "name": "demo", "scripts": { - "postinstall": "tailor-sdk skills install", - "skills": "pnpm exec tailor-sdk skills install" + "postinstall": "tailor skills add", + "skills": "pnpm exec tailor skills add" } } diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-shell/expected.sh b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-shell/expected.sh index 9d6ee6bda..478dafee5 100644 --- a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-shell/expected.sh +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-shell/expected.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash set -euo pipefail -pnpm exec tailor-sdk skills install -npx tailor-sdk skills install --help +pnpm exec tailor skills add +npx tailor skills add --help diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-yaml/expected.yml b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-yaml/expected.yml index d410ac2fd..4737b1169 100644 --- a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-yaml/expected.yml +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-yaml/expected.yml @@ -2,4 +2,4 @@ jobs: install-skills: runs-on: ubuntu-latest steps: - - run: tailor-sdk skills install + - run: tailor skills add diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/version-qualified/expected.sh b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/version-qualified/expected.sh index 8022201c7..bd85aa7e2 100644 --- a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/version-qualified/expected.sh +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/version-qualified/expected.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash -npx tailor-sdk skills install -pnpm dlx tailor-sdk skills install --help +npx tailor skills add +pnpm dlx tailor skills add --help tailor-sdk-skills-helper run diff --git a/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/codemod.yaml b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/codemod.yaml new file mode 100644 index 000000000..ededfbd3c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/tailor-output-ignore-dir" +version: "1.0.0" +description: "Rename exact ignore-file entries for the generated output directory from .tailor-sdk to .tailor" +engine: jssg +language: text +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/scripts/transform.ts new file mode 100644 index 000000000..9414bfb03 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/scripts/transform.ts @@ -0,0 +1,17 @@ +const GENERATED_DIR_IGNORE_ENTRY_RE = /^(!?\/?)\.tailor-sdk(\/?)([ \t]*)$/gm; + +/** + * Rewrite exact ignore-file entries for the generated SDK output directory. + * @param source - File contents + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string): string | null { + if (!source.includes(".tailor-sdk")) return null; + + const updated = source.replace( + GENERATED_DIR_IGNORE_ENTRY_RE, + (_match, prefix: string, slash: string, trailingWhitespace: string) => + `${prefix}.tailor${slash}${trailingWhitespace}`, + ); + return updated === source ? null : updated; +} diff --git a/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/expected.gitignore b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/expected.gitignore new file mode 100644 index 000000000..90d6c4767 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/expected.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.tailor/ +/.tailor/ +.tailor +/.tailor +!.tailor/ +dist/ diff --git a/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/input.gitignore b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/input.gitignore new file mode 100644 index 000000000..ddcc66899 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/input.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.tailor-sdk/ +/.tailor-sdk/ +.tailor-sdk +/.tailor-sdk +!.tailor-sdk/ +dist/ diff --git a/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/no-match/input.gitignore b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/no-match/input.gitignore new file mode 100644 index 000000000..ba9115457 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/no-match/input.gitignore @@ -0,0 +1,7 @@ +# .tailor-sdk/ +.tailor-sdk/cache +docs: `.tailor-sdk/` was the old generated directory +prefix/.tailor-sdk/ + .tailor-sdk/ +.tailor-sdk/extra +.tailor-sdk-old/ diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/codemod.yaml b/packages/sdk-codemod/codemods/v2/tailordb-namespace/codemod.yaml index 83219b1f8..dcd93c07b 100644 --- a/packages/sdk-codemod/codemods/v2/tailordb-namespace/codemod.yaml +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/codemod.yaml @@ -1,6 +1,6 @@ name: "@tailor-platform/tailordb-namespace" version: "1.0.0" -description: "Rename the deprecated capital-cased `Tailordb` ambient namespace to lowercase `tailordb` (e.g. `Tailordb.QueryResult` → `tailordb.QueryResult`)" +description: "Rename the removed capital-cased `Tailordb` ambient namespace to lowercase `tailordb` (e.g. `Tailordb.QueryResult` → `tailordb.QueryResult`)" engine: jssg language: typescript since: "1.0.0" diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/scripts/transform.ts index a803bbea5..29cbf88a5 100644 --- a/packages/sdk-codemod/codemods/v2/tailordb-namespace/scripts/transform.ts +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/scripts/transform.ts @@ -1,8 +1,9 @@ -// Members exposed by the deprecated `Tailordb` ambient namespace from -// `@tailor-platform/function-types`. Each was a type-only declaration that -// has been re-published under the new lowercase `tailordb` namespace by the -// SDK. Anything outside this list is left untouched so user-defined symbols -// that happen to share the `Tailordb.` prefix are not rewritten by accident. +// Members of the removed capital-cased `Tailordb` ambient namespace +// (originally from `@tailor-platform/function-types`). Each is a type-only +// declaration now available under the lowercase `tailordb` namespace exposed +// by `@tailor-platform/sdk/runtime/globals`. Anything outside this list is +// left untouched so user-defined symbols that happen to share the +// `Tailordb.` prefix are not rewritten by accident. const TAILORDB_MEMBERS = ["QueryResult", "CommandType", "Client"] as const; const MEMBER_GROUP = TAILORDB_MEMBERS.join("|"); @@ -14,10 +15,11 @@ const MEMBER_GROUP = TAILORDB_MEMBERS.join("|"); const PATTERN = new RegExp(String.raw`\bTailordb\.(${MEMBER_GROUP})\b`, "g"); /** - * Rewrite references to the deprecated capital-cased `Tailordb` ambient - * namespace to the new lowercase `tailordb` namespace. The capital-cased - * namespace was inherited from `@tailor-platform/function-types`; the SDK - * keeps it as a `@deprecated` alias in v1 and removes it in v2. + * Rewrite references to the capital-cased `Tailordb` ambient namespace to the + * lowercase `tailordb` namespace. The capital-cased namespace was inherited + * from `@tailor-platform/function-types`; the SDK kept it as a `@deprecated` + * alias in v1 and removed it in v2, leaving only the lowercase `tailordb.*` + * namespace exposed by `@tailor-platform/sdk/runtime/globals`. * * Only the known type-only members (`QueryResult`, `CommandType`, `Client`) * are rewritten so that unrelated user-defined symbols sharing the diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/codemod.yaml b/packages/sdk-codemod/codemods/v2/wait-point-rename/codemod.yaml new file mode 100644 index 000000000..35ce27041 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/wait-point-rename" +version: "1.0.0" +description: "Rename defineWaitPoint/defineWaitPoints to createWaitPoint/createWaitPoints" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/scripts/transform.ts new file mode 100644 index 000000000..dfc2cf463 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/scripts/transform.ts @@ -0,0 +1,202 @@ +import { parse, Lang } from "@ast-grep/napi"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const SDK_MODULE = "@tailor-platform/sdk"; + +const RENAMES: Record = { + defineWaitPoint: "createWaitPoint", + defineWaitPoints: "createWaitPoints", +}; + +function isInsideImportStatement(node: SgNode): boolean { + let current: SgNode | null = node.parent(); + while (current) { + if (current.kind() === "import_statement") return true; + current = current.parent(); + } + return false; +} + +/** + * Rename `defineWaitPoint` and `defineWaitPoints` imported from `@tailor-platform/sdk` + * to `createWaitPoint` and `createWaitPoints`, updating both the import specifiers + * and all usages in the file body. + * @param source - File contents + * @param filePath - Absolute path to the file (kept for the runner signature) + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, _filePath?: string): string | null { + const hasMatch = Object.keys(RENAMES).some((name) => source.includes(name)); + if (!hasMatch) return null; + if (!source.includes(SDK_MODULE)) return null; + + const lang = source.includes("") ? Lang.Tsx : Lang.TypeScript; + const root = parse(lang, source).root(); + + const edits: Edit[] = []; + // Non-aliased imports need their body references renamed too. + const needsBodyRename = new Set(); + + const importStmts = root.findAll({ + rule: { + kind: "import_statement", + has: { kind: "string", regex: `^["']${SDK_MODULE}["']$` }, + }, + }); + + for (const importStmt of importStmts) { + const specs = importStmt.findAll({ rule: { kind: "import_specifier" } }); + for (const spec of specs) { + const idents = spec.children().filter((c: SgNode) => c.kind() === "identifier"); + if (idents.length === 0) continue; + + const importedName = idents[0]!.text(); + const newName = RENAMES[importedName]; + if (!newName) continue; + + const isAliased = idents.length > 1; + edits.push(idents[0]!.replace(newName)); + if (!isAliased) needsBodyRename.add(importedName); + } + } + + if (edits.length === 0) return null; + + if (needsBodyRename.size > 0) { + // Build byte-range maps for scopes where each name is shadowed by a local declaration. + // Only identifiers outside these ranges should be renamed. + const shadowedRanges = new Map>(); + + const addShadowedRange = (name: string, scopeNode: SgNode) => { + const r = scopeNode.range(); + if (!shadowedRanges.has(name)) shadowedRanges.set(name, []); + shadowedRanges.get(name)!.push({ start: r.start.index, end: r.end.index }); + }; + + // Variable declarations and local function declarations. + const localDecls = root.findAll({ + rule: { any: [{ kind: "function_declaration" }, { kind: "variable_declarator" }] }, + }); + for (const decl of localDecls) { + if (isInsideImportStatement(decl)) continue; + // For variable_declarator: check direct identifier bindings and shorthand + // destructuring patterns (const { x } = ...) — both create local shadows. + const nameChild = + decl + .children() + .filter((c: SgNode) => c.kind() === "identifier") + .find((c: SgNode) => needsBodyRename.has(c.text())) ?? + decl + .children() + .find((c: SgNode) => c.kind() === "object_pattern") + ?.children() + .find( + (c: SgNode) => + c.kind() === "shorthand_property_identifier_pattern" && needsBodyRename.has(c.text()), + ); + if (!nameChild || !needsBodyRename.has(nameChild.text())) continue; + + // Walk up to the nearest statement_block or program — that is the scope + // where this declaration shadows the imported name. + let scopeNode: SgNode = root; + let p: SgNode | null = decl.parent(); + while (p) { + const k = p.kind(); + if ( + k === "statement_block" || + k === "program" || + k === "for_statement" || + k === "for_in_statement" + ) { + scopeNode = p; + break; + } + p = p.parent(); + } + + addShadowedRange(nameChild.text(), scopeNode); + } + + // Function/arrow parameters — covers required (param: T) and optional (param?: T). + const paramNodes = root.findAll({ + rule: { any: [{ kind: "required_parameter" }, { kind: "optional_parameter" }] }, + }); + for (const param of paramNodes) { + if (isInsideImportStatement(param)) continue; + // The name identifier may be a direct child or wrapped in rest_pattern. + const nameChild = param + .children() + .flatMap((c: SgNode) => + c.kind() === "rest_pattern" + ? c.children().filter((cc: SgNode) => cc.kind() === "identifier") + : c.kind() === "identifier" + ? [c] + : [], + ) + .find((c: SgNode) => needsBodyRename.has(c.text())); + if (!nameChild) continue; + + // Walk up past formal_parameters to the enclosing function/arrow, then use its body. + let scopeNode: SgNode = root; + let p: SgNode | null = param.parent(); + while (p) { + const k = p.kind(); + if (k === "formal_parameters") { + p = p.parent(); + continue; + } + if ( + k === "function_declaration" || + k === "function_expression" || + k === "arrow_function" || + k === "method_definition" + ) { + // Use the whole function node so the parameter list itself is also covered. + scopeNode = p; + break; + } + break; + } + + addShadowedRange(nameChild.text(), scopeNode); + } + + // for...of / for...in binding identifiers (direct children of for_in_statement, + // appearing before the 'of' or 'in' keyword). + const forInStmts = root.findAll({ rule: { kind: "for_in_statement" } }); + for (const stmt of forInStmts) { + const children = stmt.children(); + const keywordIdx = children.findIndex((c: SgNode) => c.kind() === "of" || c.kind() === "in"); + if (keywordIdx < 0) continue; + for (let i = 0; i < keywordIdx; i++) { + const child = children[i]!; + if (child.kind() === "identifier" && needsBodyRename.has(child.text())) { + addShadowedRange(child.text(), stmt); + } + } + } + + const renameNode = (node: SgNode) => { + const name = node.text(); + if (!needsBodyRename.has(name)) return; + if (isInsideImportStatement(node)) return; + const ranges = shadowedRanges.get(name); + if (ranges) { + const pos = node.range().start.index; + if (ranges.some((r) => pos >= r.start && pos < r.end)) return; + } + edits.push(node.replace(RENAMES[name]!)); + }; + + for (const ident of root.findAll({ rule: { kind: "identifier" } })) { + renameNode(ident); + } + // Shorthand property references in object literals ({ defineWaitPoints }) use a + // distinct AST node kind and must be renamed separately. + for (const prop of root.findAll({ rule: { kind: "shorthand_property_identifier" } })) { + renameNode(prop); + } + } + + return root.commitEdits(edits); +} diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/expected.ts new file mode 100644 index 000000000..2a7ab0904 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/expected.ts @@ -0,0 +1,7 @@ +import { createWaitPoints as wp, createWaitPoint as single } from "@tailor-platform/sdk"; + +export const { approval } = wp((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +export const step = single("step"); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/input.ts new file mode 100644 index 000000000..2fcfa6395 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/input.ts @@ -0,0 +1,7 @@ +import { defineWaitPoints as wp, defineWaitPoint as single } from "@tailor-platform/sdk"; + +export const { approval } = wp((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +export const step = single("step"); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/expected.ts new file mode 100644 index 000000000..8c0f6309d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/expected.ts @@ -0,0 +1,17 @@ +import { createWorkflow, createWorkflowJob, createWaitPoint, createWaitPoints } from "@tailor-platform/sdk"; + +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +export const singlePoint = createWaitPoint<{ id: string }, boolean>("my-step"); + +export const processJob = createWorkflowJob({ + name: "process", + body: async (input: { orderId: string }) => { + const result = await approval.wait({ message: `approve ${input.orderId}` }); + return { approved: result.approved }; + }, +}); + +export default createWorkflow({ name: "my-workflow", mainJob: processJob }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/input.ts new file mode 100644 index 000000000..6026086f7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/input.ts @@ -0,0 +1,17 @@ +import { createWorkflow, createWorkflowJob, defineWaitPoint, defineWaitPoints } from "@tailor-platform/sdk"; + +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +export const singlePoint = defineWaitPoint<{ id: string }, boolean>("my-step"); + +export const processJob = createWorkflowJob({ + name: "process", + body: async (input: { orderId: string }) => { + const result = await approval.wait({ message: `approve ${input.orderId}` }); + return { approved: result.approved }; + }, +}); + +export default createWorkflow({ name: "my-workflow", mainJob: processJob }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/expected.ts new file mode 100644 index 000000000..c03f074e7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/expected.ts @@ -0,0 +1,14 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Object destructuring shadows the import — calls inside should NOT be renamed +function helper(source: Record void>) { + const { defineWaitPoints } = source; + return defineWaitPoints(); +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/input.ts new file mode 100644 index 000000000..a07ac53ec --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/input.ts @@ -0,0 +1,14 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Object destructuring shadows the import — calls inside should NOT be renamed +function helper(source: Record void>) { + const { defineWaitPoints } = source; + return defineWaitPoints(); +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/expected.ts new file mode 100644 index 000000000..33428ceab --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/expected.ts @@ -0,0 +1,13 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// for-loop counter shares the name — everything inside the for is NOT renamed +for (let defineWaitPoints = 0; defineWaitPoints < 3; defineWaitPoints++) { + void defineWaitPoints; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/input.ts new file mode 100644 index 000000000..3cf7fbc06 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/input.ts @@ -0,0 +1,13 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// for-loop counter shares the name — everything inside the for is NOT renamed +for (let defineWaitPoints = 0; defineWaitPoints < 3; defineWaitPoints++) { + void defineWaitPoints; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/expected.ts new file mode 100644 index 000000000..6f7234a5c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/expected.ts @@ -0,0 +1,13 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// for...of loop variable shadows the import — everything inside the loop is NOT renamed +for (const defineWaitPoints of []) { + void defineWaitPoints; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/input.ts new file mode 100644 index 000000000..4462004e6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/input.ts @@ -0,0 +1,13 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// for...of loop variable shadows the import — everything inside the loop is NOT renamed +for (const defineWaitPoints of []) { + void defineWaitPoints; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/expected.ts new file mode 100644 index 000000000..f0ff1cd3e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/expected.ts @@ -0,0 +1,8 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Local declaration shadows the SDK import — body usages should NOT be renamed +function defineWaitPoints() {} + +defineWaitPoints(); + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/input.ts new file mode 100644 index 000000000..69061524d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/input.ts @@ -0,0 +1,8 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Local declaration shadows the SDK import — body usages should NOT be renamed +function defineWaitPoints() {} + +defineWaitPoints(); + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/expected.ts new file mode 100644 index 000000000..d0da7227d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/expected.ts @@ -0,0 +1,14 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +function helper() { + // Nested local shadow — these should NOT be renamed + function defineWaitPoints() {} + defineWaitPoints(); +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/input.ts new file mode 100644 index 000000000..70bd01d08 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/input.ts @@ -0,0 +1,14 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +function helper() { + // Nested local shadow — these should NOT be renamed + function defineWaitPoints() {} + defineWaitPoints(); +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-match/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-match/input.ts new file mode 100644 index 000000000..40c9dc885 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-match/input.ts @@ -0,0 +1,4 @@ +import { createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; + +export const job = createWorkflowJob({ name: "job", body: () => ({ ok: true }) }); +export default createWorkflow({ name: "wf", mainJob: job }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-sdk-import/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-sdk-import/input.ts new file mode 100644 index 000000000..157c91325 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-sdk-import/input.ts @@ -0,0 +1,5 @@ +// defineWaitPoints and defineWaitPoint used locally — not from @tailor-platform/sdk +function defineWaitPoints() {} +function defineWaitPoint() {} +defineWaitPoints(); +defineWaitPoint(); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/expected.ts new file mode 100644 index 000000000..204ce53e8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/expected.ts @@ -0,0 +1,13 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Optional parameter shadows the import — should NOT rename inside +function processAll(defineWaitPoints?: unknown[]) { + return defineWaitPoints?.length; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/input.ts new file mode 100644 index 000000000..8e77ef8af --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/input.ts @@ -0,0 +1,13 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Optional parameter shadows the import — should NOT rename inside +function processAll(defineWaitPoints?: unknown[]) { + return defineWaitPoints?.length; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/expected.ts new file mode 100644 index 000000000..b2383ff65 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/expected.ts @@ -0,0 +1,18 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Function parameter shadows the import — should NOT rename inside +function processAll(defineWaitPoints: unknown[]) { + return defineWaitPoints.length; +} + +// Arrow function parameter shadows the import — should NOT rename inside +const processEach = (defineWaitPoints: string) => { + return defineWaitPoints.trim(); +}; + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/input.ts new file mode 100644 index 000000000..18dbade5e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/input.ts @@ -0,0 +1,18 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Function parameter shadows the import — should NOT rename inside +function processAll(defineWaitPoints: unknown[]) { + return defineWaitPoints.length; +} + +// Arrow function parameter shadows the import — should NOT rename inside +const processEach = (defineWaitPoints: string) => { + return defineWaitPoints.trim(); +}; + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/expected.ts new file mode 100644 index 000000000..e9f7bf3d0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/expected.ts @@ -0,0 +1,6 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Object shorthand usage — should be renamed alongside the import +const api = { createWaitPoints }; + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/input.ts new file mode 100644 index 000000000..6263adc92 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/input.ts @@ -0,0 +1,6 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Object shorthand usage — should be renamed alongside the import +const api = { defineWaitPoints }; + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/codemod.yaml b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/codemod.yaml new file mode 100644 index 000000000..083e26e87 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/workflow-trigger-rename" +version: "1.0.0" +description: "Rename tailor.workflow triggerWorkflow/triggerJobFunction/resumeWorkflow call sites to startWorkflow/startJobFunction/resumeWorkflowExecution" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/scripts/transform.ts new file mode 100644 index 000000000..8a3baa6af --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/scripts/transform.ts @@ -0,0 +1,165 @@ +import { parse, Lang } from "@ast-grep/napi"; +import { + findImportStatements, + importBindings, + localDeclarationNames, +} from "../../../../src/ast-grep-helpers"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const RENAMES: Record = { + triggerWorkflow: "startWorkflow", + triggerJobFunction: "startJobFunction", + resumeWorkflow: "resumeWorkflowExecution", +}; + +const WORKFLOW_MODULE_SOURCES = new Set([ + "@tailor-platform/sdk/runtime", + "@tailor-platform/sdk/runtime/workflow", +]); + +function quickFilter(source: string): boolean { + return Object.keys(RENAMES).some((name) => source.includes(name)); +} + +function sourceLang(filePath: string, source: string): Lang { + const lower = filePath.toLowerCase(); + if (lower.endsWith(".tsx") || lower.endsWith(".jsx")) return Lang.Tsx; + return source.includes("") ? Lang.Tsx : Lang.TypeScript; +} + +function collectWorkflowLocals(root: SgNode, imports: SgNode[]): Set { + const locals = new Set(); + for (const importStmt of imports) { + for (const binding of importBindings(importStmt)) { + if (binding.importedName === "workflow" && WORKFLOW_MODULE_SOURCES.has(binding.source)) { + locals.add(binding.localName); + } + } + } + + // Coarse safety check: a local name also declared elsewhere in the file + // (shadowing the import) is dropped rather than range-tracked. + const declaredNames = localDeclarationNames(root); + for (const name of locals) { + if (declaredNames.has(name)) locals.delete(name); + } + return locals; +} + +function tailorIsAmbientGlobal(root: SgNode, imports: SgNode[]): boolean { + const declaredNames = localDeclarationNames(root); + if (declaredNames.has("tailor")) return false; + return !imports.some((stmt) => importBindings(stmt).some((b) => b.localName === "tailor")); +} + +function isAmbientTailorWorkflow(object: SgNode | null): boolean { + if (!object || object.kind() !== "member_expression") return false; + const base = object.field("object"); + const property = object.field("property"); + return ( + base?.kind() === "identifier" && base.text() === "tailor" && property?.text() === "workflow" + ); +} + +function expressionArguments(args: SgNode): SgNode[] { + return args.children().filter((child) => !["(", ")", ","].includes(child.kind() as string)); +} + +function thirdArgument(member: SgNode): SgNode | null { + const call = member.parent(); + if (call?.kind() !== "call_expression") return null; + const args = call.field("arguments"); + if (!args) return null; + return expressionArguments(args)[2] ?? null; +} + +/** + * A `triggerWorkflow(name, args, options)` call whose third argument is not a + * literal object (e.g. a variable), or is an object literal that spreads one + * (`{ ...rest }`), cannot be safely renamed: we cannot tell whether it carries + * an `invoker` key that also needs renaming to `authInvoker`, and renaming + * just the method name would erase the `triggerWorkflow` token that flags the + * call for manual review, while the option silently stops working at + * runtime. Such calls are left entirely unrenamed. + * @param member - The `.triggerWorkflow` member-expression node being considered + * @returns Whether the call must be skipped + */ +function hasUnsafeThirdArgument(member: SgNode): boolean { + const optionsArg = thirdArgument(member); + if (optionsArg === null) return false; + if (optionsArg.kind() !== "object") return true; + return optionsArg.children().some((child) => child.kind() === "spread_element"); +} + +/** + * `triggerWorkflow`'s removed SDK wrapper converted an `invoker` option to + * the platform's `authInvoker` shape before calling through; `startWorkflow` + * expects `authInvoker` directly. Build an edit renaming a literal `invoker` + * key (or shorthand) in the third argument of a `triggerWorkflow(...)` call + * being renamed to `startWorkflow`, so the option keeps working. + * @param member - The `.triggerWorkflow` member-expression node being renamed + * @returns An edit renaming the `invoker` key, or null when not applicable + */ +function findInvokerOptionEdit(member: SgNode): Edit | null { + const optionsArg = thirdArgument(member); + if (!optionsArg || optionsArg.kind() !== "object") return null; + + for (const child of optionsArg.children()) { + if (child.kind() === "pair") { + const key = child.field("key"); + if (key?.kind() === "property_identifier" && key.text() === "invoker") { + return key.replace("authInvoker"); + } + } else if (child.kind() === "shorthand_property_identifier" && child.text() === "invoker") { + return child.replace("authInvoker: invoker"); + } + } + return null; +} + +/** + * Rewrite `.triggerWorkflow(`, `.triggerJobFunction(`, and `.resumeWorkflow(` + * member accesses to their canonical `start*`/`resumeWorkflowExecution` names, + * either on the ambient `tailor.workflow` global or on a `workflow` value + * imported from `@tailor-platform/sdk/runtime(/workflow)`. + * @param source - File contents + * @param filePath - Absolute path to the file + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, filePath?: string): string | null { + if (!quickFilter(source)) return null; + + const root = parse(sourceLang(filePath ?? "", source), source).root(); + const imports = findImportStatements(root); + const workflowLocals = collectWorkflowLocals(root, imports); + const tailorIsAmbient = tailorIsAmbientGlobal(root, imports); + + const edits: Edit[] = []; + for (const member of root.findAll({ rule: { kind: "member_expression" } })) { + const property = member.field("property"); + if (!property || property.kind() !== "property_identifier") continue; + const newName = RENAMES[property.text()]; + if (!newName) continue; + + const object = member.field("object"); + if (!object) continue; + + const isWorkflowImportReceiver = + object.kind() === "identifier" && workflowLocals.has(object.text()); + const isAmbientReceiver = tailorIsAmbient && isAmbientTailorWorkflow(object); + if (!isWorkflowImportReceiver && !isAmbientReceiver) continue; + + if (newName === "startWorkflow") { + if (hasUnsafeThirdArgument(member)) continue; + edits.push(property.replace(newName)); + const invokerEdit = findInvokerOptionEdit(member); + if (invokerEdit) edits.push(invokerEdit); + } else { + edits.push(property.replace(newName)); + } + } + + if (edits.length === 0) return null; + const result = root.commitEdits(edits); + return result === source ? null : result; +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/aliased-import/expected.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/aliased-import/expected.ts new file mode 100644 index 000000000..21f466560 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/aliased-import/expected.ts @@ -0,0 +1,5 @@ +import { workflow as wf } from "@tailor-platform/sdk/runtime"; + +export async function resume(executionId: string): Promise { + return await wf.resumeWorkflowExecution(executionId); +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/aliased-import/input.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/aliased-import/input.ts new file mode 100644 index 000000000..02fa1c6e7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/aliased-import/input.ts @@ -0,0 +1,5 @@ +import { workflow as wf } from "@tailor-platform/sdk/runtime"; + +export async function resume(executionId: string): Promise { + return await wf.resumeWorkflow(executionId); +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/ambient-global/expected.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/ambient-global/expected.ts new file mode 100644 index 000000000..0034c66a7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/ambient-global/expected.ts @@ -0,0 +1,7 @@ +import "@tailor-platform/sdk/runtime/globals"; + +export async function run(): Promise { + await tailor.workflow.startWorkflow("myWorkflow", { data: "value" }); + tailor.workflow.startJobFunction("myJob", { data: "value" }); + await tailor.workflow.resumeWorkflowExecution("execution-id"); +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/ambient-global/input.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/ambient-global/input.ts new file mode 100644 index 000000000..61c2cde3c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/ambient-global/input.ts @@ -0,0 +1,7 @@ +import "@tailor-platform/sdk/runtime/globals"; + +export async function run(): Promise { + await tailor.workflow.triggerWorkflow("myWorkflow", { data: "value" }); + tailor.workflow.triggerJobFunction("myJob", { data: "value" }); + await tailor.workflow.resumeWorkflow("execution-id"); +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/invoker-option-non-literal/input.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/invoker-option-non-literal/input.ts new file mode 100644 index 000000000..18f91ae6b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/invoker-option-non-literal/input.ts @@ -0,0 +1,12 @@ +import { workflow } from "@tailor-platform/sdk/runtime"; + +const options = { invoker: { namespace: "my-auth", machineUserName: "admin" } }; + +export async function runWithVariable(): Promise { + return await workflow.triggerWorkflow("myWorkflow", { data: "value" }, options); +} + +export async function runWithSpread(): Promise { + const extra = { invoker: { namespace: "my-auth", machineUserName: "admin" } }; + return await workflow.triggerWorkflow("myWorkflow", { data: "value" }, { ...extra }); +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/invoker-option-rename/expected.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/invoker-option-rename/expected.ts new file mode 100644 index 000000000..7d141c131 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/invoker-option-rename/expected.ts @@ -0,0 +1,15 @@ +import { workflow } from "@tailor-platform/sdk/runtime"; + +const invoker = { namespace: "my-auth", machineUserName: "admin" }; + +export async function runShorthand(): Promise { + return await workflow.startWorkflow("myWorkflow", { data: "value" }, { authInvoker: invoker }); +} + +export async function runExplicitKey(): Promise { + return await workflow.startWorkflow( + "myWorkflow", + { data: "value" }, + { authInvoker: { namespace: "my-auth", machineUserName: "admin" } }, + ); +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/invoker-option-rename/input.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/invoker-option-rename/input.ts new file mode 100644 index 000000000..36c4b4f07 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/invoker-option-rename/input.ts @@ -0,0 +1,15 @@ +import { workflow } from "@tailor-platform/sdk/runtime"; + +const invoker = { namespace: "my-auth", machineUserName: "admin" }; + +export async function runShorthand(): Promise { + return await workflow.triggerWorkflow("myWorkflow", { data: "value" }, { invoker }); +} + +export async function runExplicitKey(): Promise { + return await workflow.triggerWorkflow( + "myWorkflow", + { data: "value" }, + { invoker: { namespace: "my-auth", machineUserName: "admin" } }, + ); +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/no-match/input.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/no-match/input.ts new file mode 100644 index 000000000..b423611f4 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/no-match/input.ts @@ -0,0 +1,7 @@ +const localWorkflowClient = { + triggerWorkflow(name: string): string { + return name; + }, +}; + +localWorkflowClient.triggerWorkflow("unrelated"); diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-import/expected.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-import/expected.ts new file mode 100644 index 000000000..db7aaacf6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-import/expected.ts @@ -0,0 +1,6 @@ +import { workflow } from "@tailor-platform/sdk/runtime"; + +export async function run(): Promise { + const executionId = await workflow.startWorkflow("myWorkflow", { data: "value" }); + return executionId; +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-import/input.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-import/input.ts new file mode 100644 index 000000000..82f45d195 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-import/input.ts @@ -0,0 +1,6 @@ +import { workflow } from "@tailor-platform/sdk/runtime"; + +export async function run(): Promise { + const executionId = await workflow.triggerWorkflow("myWorkflow", { data: "value" }); + return executionId; +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-workflow-subpath-import/expected.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-workflow-subpath-import/expected.ts new file mode 100644 index 000000000..80f36db5a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-workflow-subpath-import/expected.ts @@ -0,0 +1,5 @@ +import { workflow } from "@tailor-platform/sdk/runtime/workflow"; + +export function runJob(): unknown { + return workflow.startJobFunction("myJob", { data: "value" }); +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-workflow-subpath-import/input.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-workflow-subpath-import/input.ts new file mode 100644 index 000000000..7ea7091bb --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/runtime-workflow-subpath-import/input.ts @@ -0,0 +1,5 @@ +import { workflow } from "@tailor-platform/sdk/runtime/workflow"; + +export function runJob(): unknown { + return workflow.triggerJobFunction("myJob", { data: "value" }); +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/shadowed-tailor/input.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/shadowed-tailor/input.ts new file mode 100644 index 000000000..c1e862b38 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/shadowed-tailor/input.ts @@ -0,0 +1,9 @@ +interface LocalTailor { + workflow: { + triggerWorkflow: (name: string) => Promise; + }; +} + +function run(tailor: LocalTailor): Promise { + return tailor.workflow.triggerWorkflow("local"); +} diff --git a/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/shadowed-workflow/input.ts b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/shadowed-workflow/input.ts new file mode 100644 index 000000000..e47a413ec --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/workflow-trigger-rename/tests/shadowed-workflow/input.ts @@ -0,0 +1,10 @@ +import { workflow } from "@tailor-platform/sdk/runtime"; + +function run(): unknown { + const workflow = getLocalWorkflow(); + return workflow.triggerWorkflow("local"); +} + +function getLocalWorkflow(): { triggerWorkflow: (name: string) => string } { + return { triggerWorkflow: (name) => name }; +} diff --git a/packages/sdk-codemod/package.json b/packages/sdk-codemod/package.json index f3f513ee5..b6011c22c 100644 --- a/packages/sdk-codemod/package.json +++ b/packages/sdk-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@tailor-platform/sdk-codemod", - "version": "0.3.7", + "version": "0.3.0-next.7", "description": "Codemod runner for Tailor Platform SDK upgrades", "license": "MIT", "repository": { @@ -40,6 +40,7 @@ "@types/node": "24.13.3", "@types/picomatch": "4.0.3", "@types/semver": "7.7.1", + "eslint-plugin-zod": "4.7.0", "oxlint": "1.73.0", "tsdown": "0.22.7", "typescript": "6.0.3", diff --git a/packages/sdk-codemod/scripts/resolve-pending-boundaries.ts b/packages/sdk-codemod/scripts/resolve-pending-boundaries.ts new file mode 100644 index 000000000..46f758149 --- /dev/null +++ b/packages/sdk-codemod/scripts/resolve-pending-boundaries.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env tsx +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolvePendingBoundaries } from "../src/resolve-pending-boundaries"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const registryPath = resolve(scriptDir, "../src/registry.ts"); +const sdkPackageJsonPath = resolve(scriptDir, "../../sdk/package.json"); + +const sdkPackageJson = JSON.parse(await readFile(sdkPackageJsonPath, "utf-8")); +const source = await readFile(registryPath, "utf-8"); +const result = resolvePendingBoundaries(source, sdkPackageJson.version); + +if (!result.changed) { + process.stderr.write("No pending codemod boundaries to resolve.\n"); + process.exit(0); +} + +await writeFile(registryPath, result.source, "utf-8"); +process.stderr.write( + `Resolved V2_NEXT_PENDING to ${result.constantName} (${sdkPackageJson.version}).\n`, +); diff --git a/packages/sdk-codemod/scripts/sync-codemod-docs.ts b/packages/sdk-codemod/scripts/sync-codemod-docs.ts new file mode 100644 index 000000000..b6df51933 --- /dev/null +++ b/packages/sdk-codemod/scripts/sync-codemod-docs.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env tsx +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderMigrationDoc } from "../src/migration-doc"; +import { allCodemods } from "../src/registry"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +// packages/sdk-codemod/scripts -> packages/sdk/docs/migration/v2.md +const docPath = resolve(scriptDir, "../../sdk/docs/migration/v2.md"); + +function parseMode(args: string[]): "write" | "check" { + const modes = args.filter((arg) => arg === "--check" || arg === "--write"); + if (modes.length !== 1 || modes.length !== args.length) { + process.stderr.write("Usage: tsx scripts/sync-codemod-docs.ts --check|--write\n"); + process.exit(2); + } + return modes[0] === "--write" ? "write" : "check"; +} + +const mode = parseMode(process.argv.slice(2)); +const expected = renderMigrationDoc(allCodemods); + +if (mode === "write") { + await writeFile(docPath, expected, "utf-8"); + process.stderr.write(`Wrote ${docPath}\n`); +} else { + const actual = await readFile(docPath, "utf-8").catch(() => null); + if (actual !== expected) { + process.stderr.write( + "Migration doc is out of date. Run `pnpm codemod:docs:update` to regenerate it.\n", + ); + process.exit(1); + } +} diff --git a/packages/sdk-codemod/src/ast-grep-helpers.ts b/packages/sdk-codemod/src/ast-grep-helpers.ts new file mode 100644 index 000000000..007a6d051 --- /dev/null +++ b/packages/sdk-codemod/src/ast-grep-helpers.ts @@ -0,0 +1,241 @@ +import type { Edit, SgNode } from "@ast-grep/napi"; + +const DECLARATION_KINDS = [ + "function_declaration", + "function_expression", + "class_declaration", + "class", + "enum_declaration", + "interface_declaration", + "type_alias_declaration", + "internal_module", + "import_alias", +]; + +export interface ImportSpecifierNames { + importedName: string; + localName: string; + typeOnly: boolean; +} + +export interface ImportBinding { + localName: string; + importedName?: string; + source: string; + typeOnly: boolean; +} + +export interface AddNamedImportEditOptions { + importName: string; + imports: SgNode[]; + insertionIndex: (root: SgNode, imports: SgNode[], source: string) => number; + moduleName: string; + root: SgNode; + source: string; +} + +function isBindingLeafKind(kind: ReturnType): boolean { + return ( + kind === "identifier" || + kind === "type_identifier" || + kind === "shorthand_property_identifier_pattern" + ); +} + +function isBindingPatternKind(kind: ReturnType): boolean { + return ( + isBindingLeafKind(kind) || + kind === "object_pattern" || + kind === "array_pattern" || + kind === "rest_pattern" + ); +} + +export function stringValue(node: SgNode | null): string | null { + return node?.text().replace(/^['"]|['"]$/g, "") ?? null; +} + +export function importSource(importStmt: SgNode): string | null { + return stringValue(importStmt.find({ rule: { kind: "string" } }) ?? null); +} + +export function isTypeOnlyImport(importStmt: SgNode): boolean { + return importStmt.children().some((child) => child.kind() === "type"); +} + +export function namedImportsNode(importStmt: SgNode): SgNode | null { + return importStmt.find({ rule: { kind: "named_imports" } }) ?? null; +} + +export function importSpecNames(spec: SgNode): ImportSpecifierNames | null { + const ids = spec.children().filter((child) => child.kind() === "identifier"); + if (ids.length === 0) return null; + return { + importedName: ids[0]!.text(), + localName: ids[1]?.text() ?? ids[0]!.text(), + typeOnly: spec.children().some((child) => child.kind() === "type"), + }; +} + +export function findImportStatements(root: SgNode): SgNode[] { + return root + .findAll({ rule: { kind: "import_statement" } }) + .filter((stmt) => stmt.parent()?.kind() === "program") + .toSorted((a, b) => a.range().start.index - b.range().start.index); +} + +export function importBindings(importStmt: SgNode): ImportBinding[] { + const source = importSource(importStmt); + if (!source) return []; + + const requireClause = importStmt + .children() + .find((child) => child.kind() === "import_require_clause"); + if (requireClause) { + const local = requireClause.children().find((child) => child.kind() === "identifier"); + return local ? [{ localName: local.text(), source, typeOnly: false }] : []; + } + + const typeOnly = isTypeOnlyImport(importStmt); + const clause = importStmt.children().find((child) => child.kind() === "import_clause"); + if (!clause) return []; + + const bindings: ImportBinding[] = []; + for (const child of clause.children()) { + if (child.kind() === "identifier") { + bindings.push({ localName: child.text(), source, typeOnly }); + continue; + } + + if (child.kind() === "namespace_import") { + const local = child.children().find((grandchild) => grandchild.kind() === "identifier"); + if (local) bindings.push({ localName: local.text(), source, typeOnly }); + continue; + } + + if (child.kind() !== "named_imports") continue; + for (const spec of child.findAll({ rule: { kind: "import_specifier" } })) { + const names = importSpecNames(spec); + if (!names) continue; + bindings.push({ ...names, source, typeOnly: typeOnly || names.typeOnly }); + } + } + + return bindings; +} + +export function buildAddNamedImportEdit(options: AddNamedImportEditOptions): Edit { + const { importName, imports, insertionIndex, moduleName, root, source } = options; + const existingImport = + imports.find( + (importStmt) => + importSource(importStmt) === moduleName && + !isTypeOnlyImport(importStmt) && + namedImportsNode(importStmt), + ) ?? null; + const namedImports = existingImport ? namedImportsNode(existingImport) : null; + if (namedImports) { + const specTexts = namedImports.findAll({ rule: { kind: "import_specifier" } }).map((spec) => { + const names = importSpecNames(spec); + return names?.importedName === importName && names.localName === importName + ? importName + : spec.text(); + }); + const nextSpecTexts = specTexts.includes(importName) ? specTexts : [...specTexts, importName]; + return namedImports.replace(`{ ${nextSpecTexts.join(", ")} }`); + } + + const pos = insertionIndex(root, imports, source); + const insertedText = + pos === 0 || source[pos - 1] === "\n" + ? `import { ${importName} } from "${moduleName}";\n\n` + : `\nimport { ${importName} } from "${moduleName}";`; + return { startPos: pos, endPos: pos, insertedText }; +} + +export function collectBindingNames(node: SgNode, names: Set): void { + if (isBindingLeafKind(node.kind())) { + names.add(node.text()); + return; + } + + for (const child of node.children()) { + if (child.kind() === "property_identifier") continue; + if (child.kind() === "=") break; + collectBindingNames(child, names); + } +} + +function firstDeclaratorChild(node: SgNode): SgNode | null { + return node.children().find((child) => child.kind() !== "=") ?? null; +} + +function collectDirectBindingChildren(node: SgNode, names: Set): void { + for (const child of node.children()) { + if (child.kind() === "=") break; + if (isBindingPatternKind(child.kind())) collectBindingNames(child, names); + } +} + +function collectParameters(root: SgNode, names: Set): void { + for (const param of root.findAll({ + rule: { any: [{ kind: "required_parameter" }, { kind: "optional_parameter" }] }, + })) { + collectDirectBindingChildren(param, names); + } +} + +function collectDeclarationNames(root: SgNode, names: Set): void { + for (const decl of root.findAll({ rule: { any: DECLARATION_KINDS.map((kind) => ({ kind })) } })) { + const name = decl + .children() + .find((child) => child.kind() === "identifier" || child.kind() === "type_identifier"); + if (name) names.add(name.text()); + } +} + +function collectArrowParameters(root: SgNode, names: Set): void { + for (const arrow of root.findAll({ rule: { kind: "arrow_function" } })) { + const children = arrow.children(); + const arrowIndex = children.findIndex((child) => child.kind() === "=>"); + if (arrowIndex === -1) continue; + for (const child of children.slice(0, arrowIndex)) { + if (child.kind() === "=") break; + if (isBindingPatternKind(child.kind())) collectBindingNames(child, names); + } + } +} + +function collectForInBindings(root: SgNode, names: Set): void { + for (const loop of root.findAll({ rule: { kind: "for_in_statement" } })) { + const children = loop.children(); + const keywordIndex = children.findIndex( + (child) => child.kind() === "in" || child.kind() === "of", + ); + if (keywordIndex === -1) continue; + for (const child of children.slice(0, keywordIndex)) { + collectBindingNames(child, names); + } + } +} + +export function localDeclarationNames(root: SgNode): Set { + const names = new Set(); + + for (const decl of root.findAll({ rule: { kind: "variable_declarator" } })) { + const binding = firstDeclaratorChild(decl); + if (binding) collectBindingNames(binding, names); + } + + collectParameters(root, names); + collectDeclarationNames(root, names); + + for (const catchClause of root.findAll({ rule: { kind: "catch_clause" } })) { + collectDirectBindingChildren(catchClause, names); + } + + collectArrowParameters(root, names); + collectForInBindings(root, names); + + return names; +} diff --git a/packages/sdk-codemod/src/db-type-to-table-review.test.ts b/packages/sdk-codemod/src/db-type-to-table-review.test.ts new file mode 100644 index 000000000..0399d3c8f --- /dev/null +++ b/packages/sdk-codemod/src/db-type-to-table-review.test.ts @@ -0,0 +1,276 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "pathe"; +import { afterEach, describe, expect, test } from "vitest"; +import dbTypeToTableTransform from "../codemods/v2/db-type-to-table/scripts/transform"; +import { allCodemods } from "./registry"; +import { runCodemods } from "./runner"; + +const CODEMODS_DIR = path.resolve(__dirname, "../codemods"); + +const dbTypeToTable = allCodemods.find((codemod) => codemod.id === "v2/db-type-to-table"); + +if (!dbTypeToTable?.scriptPath) { + throw new Error("v2/db-type-to-table codemod is not registered with a script"); +} + +const dbTypeToTableEntry = { + codemod: dbTypeToTable, + scriptPath: path.join(CODEMODS_DIR, dbTypeToTable.scriptPath.replace(/\.js$/, ".ts")), +}; + +describe("db-type-to-table review findings", () => { + let tmpDir: string | undefined; + + afterEach(async () => { + if (tmpDir) { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + test("does not let switch-case local db declarations shadow imported db outside the switch", () => { + const input = [ + 'import { db } from "@tailor-platform/sdk";', + "", + 'export const beforeSwitch = db.type("BeforeSwitch", {', + " name: db.string(),", + "});", + "", + "switch (kind) {", + ' case "local":', + " const db = { type: (name: string) => name };", + ' db.type("NoChange");', + " break;", + "}", + "", + 'export const afterSwitch = db.type("AfterSwitch", {', + " name: db.string(),", + "});", + "", + ].join("\n"); + + const expected = [ + 'import { db } from "@tailor-platform/sdk";', + "", + 'export const beforeSwitch = db.table("BeforeSwitch", {', + " name: db.string(),", + "});", + "", + "switch (kind) {", + ' case "local":', + " const db = { type: (name: string) => name };", + ' db.type("NoChange");', + " break;", + "}", + "", + 'export const afterSwitch = db.table("AfterSwitch", {', + " name: db.string(),", + "});", + "", + ].join("\n"); + + expect(dbTypeToTableTransform(input, "tailordb.ts")).toBe(expected); + }); + + test("rewrites type-only db imports and namespace type queries", () => { + const input = [ + 'import type { db as schema } from "@tailor-platform/sdk";', + 'import * as sdk from "@tailor-platform/sdk";', + 'import type * as sdkTypes from "@tailor-platform/sdk";', + "", + "type NamedBuilder = typeof schema.type;", + "type NamespaceBuilder = typeof sdk.db.type;", + "type TypeNamespaceBuilder = typeof sdkTypes.db.type;", + "", + ].join("\n"); + + const expected = [ + 'import type { db as schema } from "@tailor-platform/sdk";', + 'import * as sdk from "@tailor-platform/sdk";', + 'import type * as sdkTypes from "@tailor-platform/sdk";', + "", + "type NamedBuilder = typeof schema.table;", + "type NamespaceBuilder = typeof sdk.db.table;", + "type TypeNamespaceBuilder = typeof sdkTypes.db.table;", + "", + ].join("\n"); + + expect(dbTypeToTableTransform(input, "tailordb.ts")).toBe(expected); + }); + + test("reports destructured db.type builders for LLM review", async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "db-type-review-test-")); + await fs.promises.writeFile( + path.join(tmpDir, "tailordb.ts"), + [ + 'import { db } from "@tailor-platform/sdk";', + "", + "const { type } = db;", + "", + 'export const user = type("User", {', + " name: db.string(),", + "});", + "", + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods([dbTypeToTableEntry], tmpDir, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/db-type-to-table", + files: ["tailordb.ts"], + findings: [ + { + file: "tailordb.ts", + line: 3, + message: "Review destructured db.type builder usage and migrate it to db.table.", + excerpt: "const { type } = db;", + }, + ], + }), + ]); + }); + + test("reports local SDK db aliases for LLM review", async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "db-type-review-test-")); + await fs.promises.writeFile( + path.join(tmpDir, "tailordb.ts"), + [ + 'import { db } from "@tailor-platform/sdk";', + 'import * as sdk from "@tailor-platform/sdk";', + "", + "const schema = db;", + "const nsSchema = sdk.db;", + "const { db: destructuredSchema } = sdk;", + "const assertedSchema = db as const;", + "let assignedSchema;", + "assignedSchema = db;", + "let nsAssignedSchema;", + "nsAssignedSchema = sdk.db;", + "", + 'export const user = schema.type("User", {', + " name: schema.string(),", + "});", + "", + 'export const team = nsSchema["type"]("Team", {', + " name: nsSchema.string(),", + "});", + "", + 'export const destructured = destructuredSchema.type("Destructured", {', + " name: destructuredSchema.string(),", + "});", + "", + 'export const asserted = assertedSchema.type("Asserted", {', + " name: assertedSchema.string(),", + "});", + "", + 'export const assigned = assignedSchema.type("Assigned", {', + " name: assignedSchema.string(),", + "});", + "", + 'export const nsAssigned = nsAssignedSchema.type("NsAssigned", {', + " name: nsAssignedSchema.string(),", + "});", + "", + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods([dbTypeToTableEntry], tmpDir, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/db-type-to-table", + files: ["tailordb.ts"], + findings: [ + { + file: "tailordb.ts", + line: 4, + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: "const schema = db;", + }, + { + file: "tailordb.ts", + line: 5, + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: "const nsSchema = sdk.db;", + }, + { + file: "tailordb.ts", + line: 6, + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: "const { db: destructuredSchema } = sdk;", + }, + { + file: "tailordb.ts", + line: 7, + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: "const assertedSchema = db as const;", + }, + { + file: "tailordb.ts", + line: 9, + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: "assignedSchema = db;", + }, + { + file: "tailordb.ts", + line: 11, + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: "nsAssignedSchema = sdk.db;", + }, + ], + }), + ]); + }); + + test("reports default parameter SDK db aliases for LLM review", async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "db-type-review-test-")); + await fs.promises.writeFile( + path.join(tmpDir, "tailordb.ts"), + [ + 'import { db } from "@tailor-platform/sdk";', + 'import * as sdk from "@tailor-platform/sdk";', + "", + "export function makeUser(schema = db) {", + ' return schema.type("User", {', + " name: schema.string(),", + " });", + "}", + "", + "export const makeTeam = (schema = sdk.db) =>", + ' schema.type("Team", {', + " name: schema.string(),", + " });", + "", + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods([dbTypeToTableEntry], tmpDir, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/db-type-to-table", + files: ["tailordb.ts"], + findings: [ + { + file: "tailordb.ts", + line: 4, + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: "export function makeUser(schema = db) {", + }, + { + file: "tailordb.ts", + line: 10, + message: "Review SDK db alias usage and migrate db.type builder calls to db.table.", + excerpt: "export const makeTeam = (schema = sdk.db) =>", + }, + ], + }), + ]); + }); +}); diff --git a/packages/sdk-codemod/src/forward-relation-name-review.test.ts b/packages/sdk-codemod/src/forward-relation-name-review.test.ts new file mode 100644 index 000000000..efc75f736 --- /dev/null +++ b/packages/sdk-codemod/src/forward-relation-name-review.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from "vitest"; +import transform, { reviewFindings } from "../codemods/v2/forward-relation-name/scripts/transform"; + +describe("forward relation name migration review", () => { + test("flags non-self relations that omit toward.as", () => { + const source = ` +const post = db.table("Post", { + authorId: db.uuid().relation({ + type: "n-1", + toward: { type: user }, + }), +}); +`; + + expect(transform(source, "post.ts")).toBeNull(); + expect(reviewFindings(source, "post.ts", "post.ts")).toMatchObject([ + { + file: "post.ts", + line: 3, + message: expect.stringContaining("toward.as"), + excerpt: expect.stringContaining("relation"), + }, + ]); + }); + + test("ignores relations whose forward name behavior is unchanged", () => { + const source = ` +const post = db.table("Post", { + authorId: db.uuid().relation({ + type: "n-1", + toward: { type: user, as: "author" }, + }), + parentId: db.uuid().relation({ + type: "n-1", + toward: { type: "self" }, + }), + ownerId: db.uuid().relation({ + type: "keyOnly", + toward: { type: user }, + }), + previousId: db.uuid().relation({ + type: "n-1", + toward: { type: "self", as: "" }, + }), + nextId: db.uuid().relation({ + type: "n-1", + toward: { type: "self", as: maybeName }, + }), +}); +`; + + expect(reviewFindings(source, "post.ts", "post.ts")).toEqual([]); + }); + + test("flags relation configs composed with spreads or shorthand properties", () => { + const source = ` +const primary = db.uuid().relation({ + ...relationConfig, + toward: { type: user }, +}); +const secondary = db.uuid().relation({ type, toward }); +`; + + const findings = reviewFindings(source, "post.ts", "post.ts"); + + expect(findings).toHaveLength(2); + }); + + test("flags relation configs with computed properties", () => { + const source = ` +const author = db.uuid().relation({ + type: "n-1", + [TOWARD]: { type: user }, +}); +`; + + const findings = reviewFindings(source, "post.ts", "post.ts"); + + expect(findings).toHaveLength(1); + }); + + test("flags relations whose toward.as value may use the default", () => { + const source = ` +const post = db.table("Post", { + authorId: db.uuid().relation({ + type: "n-1", + toward: { type: user, as: "" }, + }), + reviewerId: db.uuid().relation({ + type: "n-1", + toward: { type: user, as: undefined }, + }), + ownerId: db.uuid().relation({ + type: "n-1", + toward: { type: user, as: relationName }, + }), +}); +`; + + const findings = reviewFindings(source, "post.ts", "post.ts"); + + expect(findings).toHaveLength(3); + }); + + test("flags relation calls that use computed member access", () => { + const source = ` +const authorId = db.uuid()["relation"]({ + type: "n-1", + toward: { type: user }, +}); +`; + + const findings = reviewFindings(source, "post.ts", "post.ts"); + + expect(findings).toHaveLength(1); + }); + + test("flags relation calls that use dynamic computed member access", () => { + const source = ` +const relationMethod = "relation"; +const authorId = db.uuid()[relationMethod]({ + type: "n-1", + toward: { type: user }, +}); +`; + + const findings = reviewFindings(source, "post.ts", "post.ts"); + + expect(findings).toHaveLength(1); + }); + + test("flags multiline destructured relation builder aliases", () => { + const source = ` +const { + relation: defineRelation, +}: ReturnType = db.uuid(); +const authorId = defineRelation({ + type: "n-1", + toward: { type: user }, +}); +`; + + const findings = reviewFindings(source, "post.ts", "post.ts"); + + expect(findings).toHaveLength(1); + }); + + test("flags relation builder aliases destructured in function parameters", () => { + const source = ` +function apply({ relation }: ReturnType) { + return relation({ + type: "n-1", + toward: { type: user }, + }); +} +`; + + const findings = reviewFindings(source, "post.ts", "post.ts"); + + expect(findings).toHaveLength(1); + }); + + test("parses TypeScript extensions as TypeScript when strings contain closing tags", () => { + const source = ` +const marker = ""; +const typed = value; +const authorId = db.uuid().relation({ + type: "n-1", + toward: { type: user }, +}); +`; + + const findings = reviewFindings(source, "post.ts", "post.ts"); + + expect(findings).toHaveLength(1); + }); + + test("parses JavaScript extensions as TSX when they contain JSX", () => { + const source = `const view = ; const authorId = db.uuid().relation({ type: "n-1", toward: { type: user } });`; + + const findings = reviewFindings(source, "post.js", "post.js"); + + expect(findings).toHaveLength(1); + }); + + test("ignores malformed and unrelated relation-like calls", () => { + const source = ` +client.relation({ toward: { type: user } }); +client["validate"]({ type: "n-1", toward: { type: user } }); +const relation = { type: "n-1", toward: { type: user } }; +`; + + expect(reviewFindings(source, "post.ts", "post.ts")).toEqual([]); + }); +}); diff --git a/packages/sdk-codemod/src/index.ts b/packages/sdk-codemod/src/index.ts index b79af31f4..7b650d562 100644 --- a/packages/sdk-codemod/src/index.ts +++ b/packages/sdk-codemod/src/index.ts @@ -5,44 +5,140 @@ import * as path from "pathe"; import { readPackageJSON } from "pkg-types"; import { arg, defineCommand, runMain } from "politty"; import { z } from "zod"; -import { getApplicableCodemods, resolveCodemodScript } from "./registry"; +import { automationLevel } from "./migration-doc"; +import { allCodemods, getApplicableCodemods, resolveCodemodScript } from "./registry"; import { runCodemods } from "./runner"; -import type { RunOutput } from "./types"; +import { createRunnerMetadata } from "./runner-metadata"; +import type { LlmReview, RunOutput } from "./types"; -const packageJson = await readPackageJSON(path.dirname(fileURLToPath(import.meta.url)) + "/.."); +const packageRoot = path.dirname(fileURLToPath(import.meta.url)) + "/.."; +const packageJson = await readPackageJSON(packageRoot); +const packageName = packageJson.name ?? "sdk-codemod"; +const packageVersion = packageJson.version ?? "0.0.0"; + +/** One rule in the `list` command output. */ +interface RuleSummary { + id: string; + name: string; + /** Automatic / Partially automatic / Manual, or "Notice" for behavioral changes. */ + kind: string; + since: string; + until: string; +} + +const listCommand = defineCommand({ + name: "list", + description: "List the available codemod rules (id, name, kind, version range).", + args: z.strictObject({}), + run: () => { + const rules: RuleSummary[] = allCodemods.map((codemod) => ({ + id: codemod.id, + name: codemod.name, + kind: codemod.notice ? "Notice" : automationLevel(codemod), + since: codemod.since, + until: codemod.until, + })); + // Human-readable table to stderr; machine-readable JSON to stdout. + for (const rule of rules) { + process.stderr.write(` ${rule.id} [${rule.kind}] ${rule.name}\n`); + } + process.stdout.write(JSON.stringify(rules) + "\n"); + }, +}); + +/** + * Print an LLM-assisted review task to stderr: the flagged files plus the + * codemod's migration prompt, ready to hand to an LLM for the cases the + * deterministic transform could not complete on its own. + * @param review - The review task (codemod id, prompt, files) + */ +function printLlmReview(review: LlmReview): void { + const scope = + review.files.length > 0 + ? "the codemod cannot safely migrate these automatically" + : "review the project for this manual change"; + process.stderr.write(`\n🤖 LLM-assisted review suggested (${review.codemodId}) — ${scope}:\n`); + const findingsByFile = new Map>(); + for (const finding of review.findings ?? []) { + let findings = findingsByFile.get(finding.file); + if (!findings) { + findings = []; + findingsByFile.set(finding.file, findings); + } + findings.push(finding); + } + for (const file of review.files) { + process.stderr.write(` - ${file}\n`); + for (const finding of findingsByFile.get(file) ?? []) { + process.stderr.write(` - line ${finding.line}: ${finding.message}\n`); + process.stderr.write(` ${finding.excerpt}\n`); + } + } + process.stderr.write(`\nPrompt for an LLM:\n${review.prompt.trim()}\n`); +} const main = defineCommand({ - name: packageJson.name ?? "sdk-codemod", + name: packageName, description: packageJson.description ?? "Codemod runner for Tailor Platform SDK upgrades", - args: z - .object({ - from: arg(z.string(), { - description: "Source SDK version (the version before upgrade)", - }), - to: arg(z.string(), { - description: "Target SDK version (the version after upgrade)", - }), - target: arg(z.string().default("."), { - description: "Project directory to transform", - }), - "dry-run": arg(z.boolean().default(false), { - alias: "d", - description: "Preview changes without modifying files", - }), - }) - .strict(), + subCommands: { list: listCommand }, + notes: `Applies the codemods matching the \`--from\`/\`--to\` version range to the +\`--target\` directory, then writes a JSON summary to \`stdout\`: + +- \`filesModified\`: files a codemod changed +- \`warnings\`: files that may still need manual migration +- \`llmReviews\`: changes the codemods could not fully migrate on their own. Each + entry has the affected \`files\`, optional file-local \`findings\`, and a + \`prompt\` — hand the prompt and files to an LLM (or follow it yourself) to + finish those cases. +- \`runner\`: exact codemod runner identity. Local source builds include the + repository commit and the build command used to produce \`dist/index.js\`. + +Progress, warnings, and the LLM-review prompts are also printed to \`stderr\` in +human-readable form, so \`stdout\` stays pure JSON for piping.`, + examples: [ + { + cmd: "--from 1.64.0 --to 2.0.0", + desc: "Apply every codemod for the 1.64.0 -> 2.0.0 upgrade to the current project", + }, + { + cmd: "--from 1.64.0 --to 2.0.0 --dry-run", + desc: "Preview the changes and any LLM-review prompts without writing files", + }, + ], + args: z.strictObject({ + from: arg(z.string(), { + description: "Source SDK version (the version before upgrade)", + }), + to: arg(z.string(), { + description: "Target SDK version (the version after upgrade)", + }), + target: arg(z.string().default("."), { + description: "Project directory to transform", + }), + "dry-run": arg(z.boolean().default(false), { + alias: "d", + description: "Preview changes without modifying files", + }), + }), run: async (args) => { const targetPath = path.resolve(args.target); const dryRun = args["dry-run"]; + const runner = createRunnerMetadata({ + packageName, + packageVersion, + packageRoot, + }); const codemods = getApplicableCodemods(args.from, args.to); const output: RunOutput = { + runner, codemodsApplied: 0, codemodsSkipped: 0, filesModified: [], warnings: [], errors: [], + llmReviews: [], }; if (codemods.length === 0) { @@ -50,10 +146,10 @@ const main = defineCommand({ return; } - // Resolve script paths for all applicable codemods + // Resolve script paths for all applicable codemods (manual entries have none) const codemodEntries = codemods.map((codemod) => ({ codemod, - scriptPath: resolveCodemodScript(codemod.scriptPath), + scriptPath: codemod.scriptPath ? resolveCodemodScript(codemod.scriptPath) : undefined, })); for (const { codemod } of codemodEntries) { @@ -67,12 +163,17 @@ const main = defineCommand({ output.codemodsSkipped = codemods.length - result.appliedCodemodIds.size; output.filesModified = result.filesModified; output.warnings = result.warnings; + output.llmReviews = result.llmReviews; if (result.changed) { process.stderr.write(` ${result.filesModified.length} file(s) modified\n`); } else { process.stderr.write(" No changes needed\n"); } + + for (const review of output.llmReviews) { + printLlmReview(review); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); output.errors.push({ codemodId: "pipeline", message }); @@ -88,4 +189,4 @@ const main = defineCommand({ }, }); -void runMain(main, { version: packageJson.version }); +void runMain(main, { version: packageVersion }); diff --git a/packages/sdk-codemod/src/migration-doc.test.ts b/packages/sdk-codemod/src/migration-doc.test.ts new file mode 100644 index 000000000..783ecf56a --- /dev/null +++ b/packages/sdk-codemod/src/migration-doc.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; +import { automationLevel, renderMigrationDoc } from "./migration-doc"; +import type { CodemodPackage } from "./types"; + +function makeCodemod(overrides: Partial): CodemodPackage { + return { + id: "v2/example", + name: "Example", + description: "Example change.", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/example/scripts/transform.js", + ...overrides, + }; +} + +describe("automationLevel", () => { + test("transform with no residual signals is Automatic", () => { + expect(automationLevel(makeCodemod({}))).toBe("Automatic"); + }); + + test("transform with legacyPatterns/suspiciousPatterns/prompt is Partially automatic", () => { + expect(automationLevel(makeCodemod({ legacyPatterns: ["x"] }))).toBe("Partially automatic"); + expect(automationLevel(makeCodemod({ suspiciousPatterns: ["x"], prompt: "do it" }))).toBe( + "Partially automatic", + ); + }); + + test("no scriptPath is Manual", () => { + expect(automationLevel(makeCodemod({ scriptPath: undefined }))).toBe("Manual"); + }); +}); + +describe("renderMigrationDoc", () => { + test("renders heading, automation level, examples, and prompt", () => { + const doc = renderMigrationDoc([ + makeCodemod({ + name: "executeScript arg", + description: "Pass a value, not a string.", + suspiciousPatterns: ["executeScript"], + prompt: "Unwrap JSON.stringify in the arg.", + examples: [{ before: "arg: JSON.stringify(x)", after: "arg: x" }], + }), + ]); + + expect(doc).toContain("# Migrating to v2"); + expect(doc).toContain("## executeScript arg"); + expect(doc).toContain("**Migration:** Partially automatic"); + expect(doc).toContain("Before:\n\n```ts\narg: JSON.stringify(x)\n```"); + expect(doc).toContain("After:\n\n```ts\narg: x\n```"); + expect(doc).toContain("Prompt for an AI agent"); + expect(doc).toContain("```text\nUnwrap JSON.stringify in the arg.\n```"); + }); + + test("omits the prompt block for Automatic entries", () => { + const doc = renderMigrationDoc([makeCodemod({ name: "Auto", description: "Auto change." })]); + expect(doc).toContain("**Migration:** Automatic"); + expect(doc).not.toContain("
"); + }); + + test("renders notices in a separate section without a migration label", () => { + const doc = renderMigrationDoc([ + makeCodemod({ name: "Real migration", description: "Edit your code." }), + makeCodemod({ + name: "Keyring storage", + description: "Tokens move to the OS keyring.", + scriptPath: undefined, + notice: true, + }), + ]); + + expect(doc).toContain("## Behavioral changes (no migration required)"); + // The notice is a sub-section, not labelled as a migration. + expect(doc).toContain("### Keyring storage"); + expect(doc.split("### Keyring storage")[1]).not.toContain("**Migration:**"); + // A real migration keeps its migration label and stays out of the notices section. + expect(doc).toContain("## Real migration"); + expect(doc.indexOf("## Real migration")).toBeLessThan(doc.indexOf("## Behavioral changes")); + }); +}); diff --git a/packages/sdk-codemod/src/migration-doc.ts b/packages/sdk-codemod/src/migration-doc.ts new file mode 100644 index 000000000..02ab5c0c2 --- /dev/null +++ b/packages/sdk-codemod/src/migration-doc.ts @@ -0,0 +1,120 @@ +import type { CodemodPackage } from "./types"; + +export type AutomationLevel = "Automatic" | "Partially automatic" | "Manual"; + +/** + * Classify how much of a migration the codemod automates. + * - `Automatic`: a transform fully covers it, with no residual to flag. + * - `Partially automatic`: a transform covers the common cases but flags + * residuals (via `legacyPatterns`/`sourceStringLegacyPatterns`/ + * `sourceTextLegacyPatterns`/`suspiciousPatterns`/ + * `sourceStringSuspiciousPatterns`/`prompt`) to finish. + * - `Manual`: no transform; the change is migrated by hand (optionally guided + * by a `prompt`). Whether a person or an LLM does it does not matter here. + * @param codemod - The codemod registry entry + * @returns The automation level + */ +export function automationLevel(codemod: CodemodPackage): AutomationLevel { + if (!codemod.scriptPath) return "Manual"; + const flagsResidual = + (codemod.legacyPatterns?.length ?? 0) > 0 || + (codemod.sourceStringLegacyPatterns?.length ?? 0) > 0 || + (codemod.sourceTextLegacyPatterns?.length ?? 0) > 0 || + (codemod.suspiciousPatterns?.length ?? 0) > 0 || + (codemod.sourceStringSuspiciousPatterns?.length ?? 0) > 0 || + codemod.prompt != null; + return flagsResidual ? "Partially automatic" : "Automatic"; +} + +function renderEntry(codemod: CodemodPackage): string { + const level = automationLevel(codemod); + const lines: string[] = [`## ${codemod.name}`, "", `**Migration:** ${level}`, ""]; + lines.push(codemod.description, ""); + + for (const example of codemod.examples ?? []) { + const fence = "```" + (example.lang ?? "ts"); + if (example.caption) lines.push(example.caption, ""); + lines.push( + "Before:", + "", + fence, + example.before, + "```", + "", + "After:", + "", + fence, + example.after, + "```", + "", + ); + } + + if (level !== "Automatic" && codemod.prompt != null) { + const summary = + level === "Manual" + ? "Prompt for an AI agent (to perform this migration)" + : "Prompt for an AI agent (to finish the cases the codemod could not migrate)"; + lines.push( + "
", + `${summary}`, + "", + "```text", + codemod.prompt.trimEnd(), + "```", + "", + "
", + "", + ); + } + + return lines.join("\n"); +} + +/** Render an informational behavioral-change notice (no migration). */ +function renderNotice(codemod: CodemodPackage): string { + return [`### ${codemod.name}`, "", codemod.description, ""].join("\n"); +} + +/** + * Render the v2 migration guide from the codemod registry. The registry is the + * single source of truth; missing detail is added to the codemod definitions. + * @param codemods - All registered codemods, in registration order + * @returns The migration guide as Markdown + */ +export function renderMigrationDoc(codemods: CodemodPackage[]): string { + // NOTE: This generator is currently v1→v2-specific: the title, `v2.md` output + // path, and `v2/*` rule ids / `until: "2.0.0"` ranges are all hardcoded. + // Supporting future major migrations would require parameterizing the target + // version, output path, and rule namespace. + const header = [ + "# Migrating to v2", + "", + "", + "", + "Run the codemods, then finish anything reported as not migrated automatically:", + "", + "```sh", + "npx @tailor-platform/sdk-codemod --from --to ", + "```", + "", + ].join("\n"); + + const migrations = codemods.filter((c) => !c.notice); + const notices = codemods.filter((c) => c.notice); + + const sections = [header, migrations.map(renderEntry).join("\n")]; + if (notices.length > 0) { + sections.push( + [ + "## Behavioral changes (no migration required)", + "", + "These v2 changes alter runtime or CLI behavior; no source change is needed.", + "", + notices.map(renderNotice).join("\n"), + ].join("\n"), + ); + } + + return `${sections.join("\n")}`.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; +} diff --git a/packages/sdk-codemod/src/principal-unify-review.test.ts b/packages/sdk-codemod/src/principal-unify-review.test.ts new file mode 100644 index 000000000..761b1c442 --- /dev/null +++ b/packages/sdk-codemod/src/principal-unify-review.test.ts @@ -0,0 +1,480 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "pathe"; +import { afterEach, describe, expect, test } from "vitest"; +import { allCodemods } from "./registry"; +import { runCodemods } from "./runner"; + +const CODEMODS_DIR = path.resolve(__dirname, "../codemods"); + +const principalUnify = allCodemods.find((codemod) => codemod.id === "v2/principal-unify"); + +if (!principalUnify?.scriptPath) { + throw new Error("v2/principal-unify codemod is not registered with a script"); +} + +const principalUnifyEntry = { + codemod: principalUnify, + scriptPath: path.join(CODEMODS_DIR, principalUnify.scriptPath.replace(/\.js$/, ".ts")), +}; + +describe("principal-unify review findings", () => { + let tmpDir: string | undefined; + + afterEach(async () => { + if (tmpDir) { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function writeProjectFile(relative: string, source: string): Promise { + tmpDir ??= await fs.promises.mkdtemp(path.join(os.tmpdir(), "principal-review-test-")); + const file = path.join(tmpDir, relative); + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.writeFile(file, source, "utf-8"); + } + + test("reports nullable caller values passed to non-null-looking calls", async () => { + await writeProjectFile( + "resolvers/order.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare const db: any;", + "declare function publishAudit(userId: string): Promise;", + "", + "export const resolver = createResolver({", + " body: async (context) => {", + ' await db.selectFrom("orders").where("createdBy", "=", context.user.id).execute();', + " await publishAudit(context.user.id);", + " const maybeId = context.user.id;", + " return maybeId;", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/order.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/order.ts", + line: 8, + message: expect.stringContaining("Kysely predicate"), + excerpt: expect.stringContaining("context.caller?.id"), + }), + expect.objectContaining({ + file: "resolvers/order.ts", + line: 9, + message: expect.stringContaining("non-null argument"), + excerpt: expect.stringContaining("publishAudit(context.caller?.id)"), + }), + ], + }), + ]); + }); + + test("reports context.user helper adapters called with resolver contexts", async () => { + await writeProjectFile( + "resolvers/customer.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "function createContext(context: any) {", + " return {", + " userId: context.user.id,", + " userType: context.user.type,", + " };", + "}", + "", + "export const resolver = createResolver({", + " body: async (context) => createContext(context),", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/customer.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/customer.ts", + line: 3, + message: expect.stringContaining("createContext"), + excerpt: expect.stringContaining("function createContext"), + }), + expect.objectContaining({ + file: "resolvers/customer.ts", + line: 11, + message: expect.stringContaining("createContext"), + excerpt: expect.stringContaining("createContext(context)"), + }), + ], + }), + ]); + }); + + test("reports helper adapters that destructure context.user", async () => { + await writeProjectFile( + "resolvers/destructured-helper.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "function createContext(context: any) {", + " const { user } = context;", + " return { userId: user.id };", + "}", + "", + "export const resolver = createResolver({", + " body: async (context) => createContext(context),", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/destructured-helper.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/destructured-helper.ts", + line: 3, + message: expect.stringContaining("createContext"), + }), + expect.objectContaining({ + file: "resolvers/destructured-helper.ts", + line: 9, + message: expect.stringContaining("createContext"), + }), + ], + }), + ]); + }); + + test("reports helper adapters with destructured context parameters", async () => { + await writeProjectFile( + "resolvers/destructured-param-helper.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "function createContext({ user }: any) {", + " return { userId: user.id };", + "}", + "", + "export const resolver = createResolver({", + " body: async (context) => createContext(context),", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/destructured-param-helper.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/destructured-param-helper.ts", + line: 3, + message: + "Helper adapter createContext reads user and needs v2 caller/invoker semantics.", + }), + expect.objectContaining({ + file: "resolvers/destructured-param-helper.ts", + line: 8, + message: + "createContext(context) passes an SDK resolver context into a helper that reads user.", + }), + ], + }), + ]); + }); + + test("reports helper adapters with aliased destructured context parameters", async () => { + await writeProjectFile( + "resolvers/aliased-destructured-param-helper.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "function createContext({ user: currentUser }: any) {", + " return { userId: currentUser.id };", + "}", + "", + "export const resolver = createResolver({", + " body: async (context) => createContext(context),", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/aliased-destructured-param-helper.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/aliased-destructured-param-helper.ts", + line: 3, + message: + "Helper adapter createContext reads currentUser and needs v2 caller/invoker semantics.", + }), + expect.objectContaining({ + file: "resolvers/aliased-destructured-param-helper.ts", + line: 8, + message: + "createContext(context) passes an SDK resolver context into a helper that reads currentUser.", + }), + ], + }), + ]); + }); + + test("keeps file-level suspicious-pattern fallback without precise findings", async () => { + await writeProjectFile( + "resolvers/context-type.ts", + [ + 'import type { ResolverContext } from "@tailor-platform/sdk";', + "", + "export type AdapterContext = ResolverContext;", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/context-type.ts"], + }), + ]); + expect(result.llmReviews[0]).not.toHaveProperty("findings"); + }); + + test("reports nullable aliased caller values passed to non-null-looking calls", async () => { + await writeProjectFile( + "resolvers/aliased.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare function publishAudit(userId: string): Promise;", + "", + "export const resolver = createResolver({", + " body: async ({ user: currentUser }) => {", + " await publishAudit(currentUser.id);", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/aliased.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/aliased.ts", + line: 7, + message: expect.stringContaining("non-null argument"), + excerpt: expect.stringContaining("publishAudit(currentUser?.id)"), + }), + ], + }), + ]); + }); + + test("reports nullable caller objects passed directly to non-null-looking calls", async () => { + await writeProjectFile( + "resolvers/direct-caller.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare function publishAudit(user: { id: string }): Promise;", + "", + "export const contextResolver = createResolver({", + " body: async (context) => {", + " await publishAudit(context.user);", + " },", + "});", + "", + "export const destructuredResolver = createResolver({", + " body: async ({ user }) => {", + " await publishAudit(user);", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/direct-caller.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/direct-caller.ts", + line: 7, + message: expect.stringContaining("non-null argument"), + excerpt: expect.stringContaining("publishAudit(context.caller)"), + }), + expect.objectContaining({ + file: "resolvers/direct-caller.ts", + line: 13, + message: expect.stringContaining("non-null argument"), + excerpt: expect.stringContaining("publishAudit(caller)"), + }), + ], + }), + ]); + }); + + test("reports aliases assigned from nullable caller values", async () => { + await writeProjectFile( + "resolvers/assigned-alias.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare function publishAudit(userId: string): Promise;", + "", + "export const resolver = createResolver({", + " body: async (context) => {", + " const user = context.user;", + " await publishAudit(user.id);", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/assigned-alias.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/assigned-alias.ts", + line: 8, + message: expect.stringContaining("non-null argument"), + excerpt: expect.stringContaining("publishAudit(user.id)"), + }), + ], + }), + ]); + }); + + test("does not report unrelated caller properties as nullable principals", async () => { + await writeProjectFile( + "resolvers/domain-caller.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare function publishAudit(userId: string): Promise;", + "", + "export const resolver = createResolver({", + " body: async ({ user }) => {", + " const event = { caller: { id: user.id } };", + " await publishAudit(event.caller.id);", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews.flatMap((review) => review.findings ?? [])).toEqual([]); + }); + + test("does not report SDK field parse invoker arguments", async () => { + await writeProjectFile( + "resolvers/parse-invoker.ts", + [ + 'import { createResolver, t } from "@tailor-platform/sdk";', + "", + "const nameField = t.string();", + "", + "export const resolver = createResolver({", + " body: async ({ input, user }) => {", + " return nameField.parse({ value: input.name, user });", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews.flatMap((review) => review.findings ?? [])).toEqual([]); + }); + + test("does not report nested SDK field parse invoker arguments", async () => { + await writeProjectFile( + "resolvers/nested-parse-invoker.ts", + [ + 'import { createResolver, t } from "@tailor-platform/sdk";', + "", + "const nameField = t.string();", + "declare function wrap(value: unknown): unknown;", + "", + "export const resolver = createResolver({", + " body: async ({ input, user }) => {", + " return wrap(nameField.parse({ value: input.name, invoker: user.id }));", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews.flatMap((review) => review.findings ?? [])).toEqual([]); + }); + + test("does not report matching alias names outside the resolver scope", async () => { + await writeProjectFile( + "resolvers/scoped-alias.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare function publishAudit(userId: string | undefined): Promise;", + "", + "export const resolver = createResolver({", + " body: async ({ user: currentUser }) => currentUser.id,", + "});", + "", + "async function audit(currentUser?: { id: string }) {", + " await publishAudit(currentUser?.id);", + "}", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([]); + }); +}); diff --git a/packages/sdk-codemod/src/registry.test.ts b/packages/sdk-codemod/src/registry.test.ts index 23da4125f..cb571654a 100644 --- a/packages/sdk-codemod/src/registry.test.ts +++ b/packages/sdk-codemod/src/registry.test.ts @@ -1,5 +1,8 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import picomatch from "picomatch"; import { describe, expect, test } from "vitest"; -import { getApplicableCodemods } from "./registry"; +import { V2_NEXT_PENDING, allCodemods, getApplicableCodemods } from "./registry"; describe("getApplicableCodemods", () => { test("returns codemods when upgrading across their version boundary", () => { @@ -8,10 +11,165 @@ describe("getApplicableCodemods", () => { expect(codemods[0]!.id).toBe("v2/define-generators-to-plugins"); }); + test("returns all v2 codemods when upgrading to the stable boundary", () => { + expect(getApplicableCodemods("1.67.1", "2.0.0").map((codemod) => codemod.id)).toEqual( + allCodemods.map((codemod) => codemod.id), + ); + }); + + test("bundles every registered transform script", () => { + const tsdownConfig = fs.readFileSync(path.resolve(__dirname, "../tsdown.config.ts"), "utf-8"); + const missing = allCodemods + .flatMap((codemod) => (codemod.scriptPath ? [codemod.scriptPath] : [])) + .map((scriptPath) => `codemods/${scriptPath.replace(/\.js$/, ".ts")}`) + .filter((scriptPath) => !tsdownConfig.includes(scriptPath)); + + expect(missing).toEqual([]); + }); + + test("returns codemods when upgrading to a prerelease at their version boundary", () => { + const prereleaseCodemods = getApplicableCodemods("1.67.1", "2.0.0-next.2"); + const prereleaseIds = prereleaseCodemods.map((codemod) => codemod.id); + + expect(prereleaseIds).toEqual( + allCodemods + .filter( + (codemod) => + codemod.prereleaseUntil === "2.0.0-next.1" || + codemod.prereleaseUntil === "2.0.0-next.2", + ) + .map((codemod) => codemod.id), + ); + expect(prereleaseIds).not.toContain("v2/auth-attributes-rename"); + expect(prereleaseIds).not.toContain("v2/db-type-to-table"); + expect(prereleaseIds).not.toContain("v2/env-var-rename"); + expect(prereleaseIds).not.toContain("v2/rename-bin"); + expect(prereleaseIds).not.toContain("v2/node-minimum-22-15-0"); + }); + + test("returns db.type to db.table codemod for the prerelease that removes db.type", () => { + const prereleaseIds = getApplicableCodemods("1.67.1", "2.0.0-next.4").map( + (codemod) => codemod.id, + ); + + expect(prereleaseIds).toContain("v2/db-type-to-table"); + }); + + test("reviews forward relation names at the prerelease that changes their defaults", () => { + const previousIds = getApplicableCodemods("1.67.1", "2.0.0-next.4").map( + (codemod) => codemod.id, + ); + const codemod = getApplicableCodemods("2.0.0-next.4", "2.0.0-next.5").find( + (entry) => entry.id === "v2/forward-relation-name", + ); + + expect(previousIds).not.toContain("v2/forward-relation-name"); + expect(codemod?.scriptPath).toBe("v2/forward-relation-name/scripts/transform.js"); + expect(codemod?.prompt).toContain("toward.as"); + expect(codemod?.prompt).toContain("GraphQL"); + expect(codemod?.prompt).toContain("non-empty"); + expect(codemod?.prompt).toContain("empty or dynamic"); + + const aliasPattern = codemod?.suspiciousPatterns?.[0]; + expect(aliasPattern).toBeInstanceOf(RegExp); + expect((aliasPattern as RegExp).test("const relation = db.uuid().relation;")).toBe(true); + expect((aliasPattern as RegExp).test("db.uuid().relation({ type, toward });")).toBe(false); + + const aliasPatterns = codemod?.suspiciousPatterns as RegExp[]; + expect(aliasPatterns.some((pattern) => pattern.test("const { relation } = db.uuid();"))).toBe( + true, + ); + expect( + aliasPatterns.some((pattern) => pattern.test('db.uuid()["relation"]({ type, toward })')), + ).toBe(true); + }); + test("returns empty when both versions are before the codemod boundary", () => { expect(getApplicableCodemods("1.0.0", "1.5.0")).toEqual([]); }); + test("uses each codemod's prerelease boundary", () => { + const ids = getApplicableCodemods("1.67.1", "2.0.0-next.1").map((codemod) => codemod.id); + const authInvokerCallUnwrap = getApplicableCodemods("1.67.1", "2.0.0-next.1").find( + (codemod) => codemod.id === "v2/auth-invoker-call-unwrap", + ); + + expect(ids).toContain("v2/test-run-arg-input"); + expect(ids).toContain("v2/auth-invoker-call-unwrap"); + expect(authInvokerCallUnwrap?.suspiciousPatterns).toEqual(["auth.invoker"]); + expect(authInvokerCallUnwrap?.reviewSupersededBy).toEqual(["v2/auth-invoker-unwrap"]); + expect(ids).not.toContain("v2/execute-script-arg"); + expect(ids).not.toContain("v2/principal-unify"); + }); + + test("throws when a prerelease boundary is not a prerelease version", () => { + allCodemods.push({ + id: "v2/invalid-prerelease-boundary", + name: "Invalid prerelease boundary", + description: "Invalid prerelease boundary", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: "2.0.0", + }); + + try { + expect(() => getApplicableCodemods("1.0.0", "2.0.0-next.1")).toThrow( + "Codemod v2/invalid-prerelease-boundary prereleaseUntil must be a prerelease version: 2.0.0", + ); + } finally { + allCodemods.pop(); + } + }); + + test("returns empty when the source prerelease already reached the codemod boundary", () => { + expect(getApplicableCodemods("2.0.0-next.2", "2.0.0-next.2")).toEqual([]); + }); + + describe("a codemod pinned to V2_NEXT_PENDING", () => { + const pending = { + id: "v2/pending-boundary-test", + name: "Pending boundary test", + description: "Pending boundary test", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_PENDING, + }; + + test("never applies while upgrading between prereleases", () => { + allCodemods.push(pending); + try { + expect(getApplicableCodemods("2.0.0-next.4", "2.0.0-next.5")).not.toContain(pending); + } finally { + allCodemods.pop(); + } + }); + + test("still applies once the target reaches the stable boundary, as a fallback", () => { + allCodemods.push(pending); + try { + expect(getApplicableCodemods("2.0.0-next.4", "2.0.0")).toContain(pending); + } finally { + allCodemods.pop(); + } + }); + }); + + test("runs stable-only codemods when upgrading from a prerelease to stable", () => { + const ids = getApplicableCodemods("2.0.0-next.2", "2.0.0").map((codemod) => codemod.id); + + expect(ids).toContain("v2/auth-attributes-rename"); + expect(ids).toContain("v2/db-type-to-table"); + expect(ids).toContain("v2/env-var-rename"); + expect(ids).toContain("v2/rename-bin"); + expect(ids).toContain("v2/node-minimum-22-15-0"); + expect(ids).not.toContain("v2/principal-unify"); + expect(ids).not.toContain("v2/auth-invoker-unwrap"); + }); + + test("returns empty when the target prerelease is before the codemod boundary", () => { + expect(getApplicableCodemods("1.67.1", "1.99.0-next.1")).toEqual([]); + }); + test("returns empty when both versions are after the codemod boundary", () => { expect(getApplicableCodemods("2.0.0", "3.0.0")).toEqual([]); }); @@ -24,4 +182,202 @@ describe("getApplicableCodemods", () => { expect(() => getApplicableCodemods("invalid", "2.0.0")).toThrow("Invalid fromVersion"); expect(() => getApplicableCodemods("1.0.0", "invalid")).toThrow("Invalid toVersion"); }); + + test("apply-to-deploy scans source files with embedded CLI strings", () => { + const applyToDeploy = getApplicableCodemods("1.67.1", "2.0.0").find( + (codemod) => codemod.id === "v2/apply-to-deploy", + ); + + expect(applyToDeploy?.filePatterns).toEqual( + expect.arrayContaining(["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"]), + ); + }); + + test("env-var-rename scans env files, CI configs, and source files", () => { + const envVarRename = getApplicableCodemods("1.67.1", "2.0.0").find( + (codemod) => codemod.id === "v2/env-var-rename", + ); + + expect(envVarRename?.filePatterns).toEqual( + expect.arrayContaining([ + "**/.env", + "**/.env.*", + "**/*.{env,sh,bash,zsh,yml,yaml,json,md}", + "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + ]), + ); + expect(envVarRename?.legacyPatterns).toContain("TAILOR_PLATFORM_SDK_CONFIG_PATH"); + expect(envVarRename?.legacyPatterns).toContain("TAILOR_TOKEN"); + expect(envVarRename?.sourceStringLegacyPatterns).toEqual( + expect.arrayContaining(["PLATFORM_URL", "PLATFORM_OAUTH2_CLIENT_ID", "LOG_LEVEL"]), + ); + }); + + test("rename-bin scans source files and declaration comments", () => { + const sourcePattern = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"; + const renameBin = getApplicableCodemods("1.67.1", "2.0.0").find( + (codemod) => codemod.id === "v2/rename-bin", + ); + + expect(renameBin?.filePatterns).toEqual(expect.arrayContaining([sourcePattern])); + expect(renameBin?.sourceStringLegacyPatterns).toHaveLength(3); + expect(renameBin?.sourceTextLegacyPatterns).toHaveLength(3); + const sourceStringPatterns = renameBin?.sourceStringLegacyPatterns as RegExp[]; + const matchesSourceStringPattern = (value: string) => + sourceStringPatterns.some((pattern) => pattern.test(value)); + expect(matchesSourceStringPattern("tailor-sdk deploy")).toBe(true); + expect(matchesSourceStringPattern("tailor-sdk apply")).toBe(true); + expect(matchesSourceStringPattern('sh -c "tailor-sdk apply"')).toBe(true); + expect(matchesSourceStringPattern('bash -lc "tailor-sdk crash-report list"')).toBe(true); + expect(matchesSourceStringPattern('Run "tailor-sdk crash-report list" manually')).toBe(true); + expect(matchesSourceStringPattern("tailor-sdk.cmd crash-report list")).toBe(true); + expect(matchesSourceStringPattern("tailor --profile tailor-sdk deploy")).toBe(false); + expect(matchesSourceStringPattern("tailor --name tailor-sdk deploy")).toBe(false); + expect(matchesSourceStringPattern('tailor --arg "tailor-sdk deploy" deploy')).toBe(false); + expect(matchesSourceStringPattern('tailor --arg "tailor-sdk apply" deploy')).toBe(false); + expect(matchesSourceStringPattern("tailor --name 'tailor-sdk crash-report list' deploy")).toBe( + false, + ); + const matches = picomatch(renameBin?.filePatterns ?? [], { dot: true }); + expect(matches("packages/app/frontend/e2e/global-setup.ts")).toBe(true); + expect(matches("tailor.d.ts")).toBe(true); + }); + + test("flags source files for runtime globals review", () => { + const codemod = allCodemods.find((entry) => entry.id === "v2/runtime-globals-opt-in"); + + expect(codemod?.scriptPath).toBe("v2/runtime-globals-opt-in/scripts/transform.js"); + expect(codemod?.filePatterns).toContain("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"); + expect(codemod?.suspiciousPatterns).toContain("tailor.idp"); + expect(codemod?.suspiciousPatterns).toContain("tailor.secretmanager"); + expect(codemod?.suspiciousPatterns).toContain("tailor.authconnection"); + expect(codemod?.sourceStringSuspiciousPatterns).toContain("new tailor.idp.Client"); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => pattern instanceof RegExp && pattern.test("const C = tailor.idp.Client;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && pattern.test("await tailor.secretmanager.getSecret();"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && pattern.test("const { getSecret } = tailor.secretmanager;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && + pattern.test("const getInvoker = tailor.context.getInvoker;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => pattern instanceof RegExp && pattern.test("const { upload } = tailordb.file;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => pattern instanceof RegExp && pattern.test("const e: TailorErrors = err;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && pattern.test("type U = Promise;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && pattern.test("type Ctor = typeof tailordb.Client;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => pattern instanceof RegExp && pattern.test("return tailordb.Client;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => pattern instanceof RegExp && pattern.test("foo(tailordb.Client);"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && pattern.test("type F = () => tailordb.QueryResult;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && + pattern.test("type R = Promise>;"), + ), + ).toBe(true); + expect(codemod?.prompt).toContain("@tailor-platform/sdk/runtime/globals"); + }); + + test("leads runtime globals migration with the typed wrappers", () => { + const codemod = allCodemods.find((entry) => entry.id === "v2/runtime-globals-opt-in"); + + expect(codemod?.prompt).toContain("new idp.Client(...)"); + expect(codemod?.examples?.[0]?.after).toContain( + 'import { idp } from "@tailor-platform/sdk/runtime"', + ); + }); + + test("execute-script-arg reviews unresolved arg stringification patterns", () => { + const executeScriptArg = getApplicableCodemods("1.67.1", "2.0.0").find( + (codemod) => codemod.id === "v2/execute-script-arg", + ); + const argPattern = executeScriptArg?.suspiciousPatterns?.find( + (pattern): pattern is [string, string, RegExp] => + Array.isArray(pattern) && pattern[2] instanceof RegExp, + )?.[2]; + + expect(argPattern?.test("arg: value")).toBe(true); + expect(argPattern?.test("arg : value")).toBe(true); + expect(argPattern?.test("arg = value")).toBe(true); + expect(argPattern?.test('"arg" : value')).toBe(true); + expect(argPattern?.test('["arg"] = value')).toBe(true); + }); + + test("flags principal migration follow-ups for review", () => { + const codemod = allCodemods.find((entry) => entry.id === "v2/principal-unify"); + + expect(codemod?.suspiciousPatterns).toContain("context.user"); + expect(codemod?.suspiciousPatterns).toContain("caller?."); + expect(codemod?.prompt).toContain("anonymous callers"); + }); + + test("open-download-stream review is scoped to deprecated API names", () => { + const openDownloadStream = getApplicableCodemods("1.67.1", "2.0.0").find( + (codemod) => codemod.id === "v2/open-download-stream", + ); + + expect(openDownloadStream?.suspiciousPatterns).toEqual( + expect.arrayContaining(["openDownloadStream", "openFileDownloadStream"]), + ); + }); + + test("auth connection token helper review is scoped to deprecated helper calls", () => { + const codemod = getApplicableCodemods("1.67.1", "2.0.0-next.2").find( + (entry) => entry.id === "v2/auth-connection-token-helper", + ); + const pattern = codemod?.suspiciousPatterns?.[0]; + + expect(codemod?.scriptPath).toBe("v2/auth-connection-token-helper/scripts/transform.js"); + expect(codemod?.filePatterns).toContain("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"); + expect(pattern).toBeUndefined(); + expect(codemod?.prompt).toContain("@tailor-platform/sdk/runtime"); + expect(codemod?.prompt).toContain("non-call"); + expect(codemod?.prompt).toContain("destructuring"); + }); }); diff --git a/packages/sdk-codemod/src/registry.ts b/packages/sdk-codemod/src/registry.ts index 5cff28cda..d3386cca4 100644 --- a/packages/sdk-codemod/src/registry.ts +++ b/packages/sdk-codemod/src/registry.ts @@ -1,11 +1,118 @@ import * as url from "node:url"; import * as path from "pathe"; -import { lt, gte, valid } from "semver"; +import { gte, lt, parse, valid } from "semver"; import type { CodemodPackage } from "./types"; const CODEMODS_ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "codemods"); +const RENAME_BIN_SOURCE_VALUE_FLAGS = [ + "--env-file-if-exists", + "--env-file", + "--profile", + "--config", + "--workspace-id", + "--arg", + "--query", + "--file", + "--name", + "--namespace", + "--dir", + "-e", + "-p", + "-c", + "-w", + "-a", + "-q", + "-f", + "-n", +]; +const RENAME_BIN_SOURCE_COMMANDS = [ + "api", + "apply", + "authconnection", + "completion", + "crash-report", + "crashreport", + "deploy", + "executor", + "function", + "generate", + "init", + "login", + "logout", + "machineuser", + "oauth2client", + "open", + "organization", + "profile", + "query", + "remove", + "secret", + "setup", + "show", + "skills", + "staticwebsite", + "tailordb", + "upgrade", + "user", + "workflow", + "workspace", +]; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const RENAME_BIN_SOURCE_VALUE_GUARDS = RENAME_BIN_SOURCE_VALUE_FLAGS.flatMap((flag) => { + const escaped = escapeRegExp(flag); + return [`(? subpath.", + ].join("\n"), }, { id: "v2/plugin-cli-import", @@ -23,78 +155,1070 @@ const allCodemods: CodemodPackage[] = [ "Rewrite deprecated plugin re-export imports (kyselyTypePlugin, enumConstantsPlugin, fileUtilsPlugin, seedPlugin) from `@tailor-platform/sdk/cli` to their dedicated plugin subpaths", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/plugin-cli-import/scripts/transform.js", + examples: [ + { + before: 'import { kyselyTypePlugin } from "@tailor-platform/sdk/cli";', + after: 'import { kyselyTypePlugin } from "@tailor-platform/sdk/plugin/kysely-type";', + }, + ], }, { id: "v2/test-run-arg-input", name: "function test-run --arg input unwrap", description: - "Strip the deprecated {input: ...} wrapper from `tailor-sdk function test-run --arg` JSON in scripts and docs", + "Strip the deprecated {input: ...} wrapper from `tailor function test-run --arg` JSON in scripts and docs", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/test-run-arg-input/scripts/transform.js", filePatterns: ["**/package.json", "**/*.{sh,bash,zsh}", "**/*.md"], + examples: [ + { + lang: "sh", + before: 'tailor function test-run resolvers/add.ts --arg \'{"input":{"a":1}}\'', + after: "tailor function test-run resolvers/add.ts --arg '{\"a\":1}'", + }, + ], }, { id: "v2/sdk-skills-shim", - name: "tailor-sdk-skills → tailor-sdk skills install", - description: - "Replace deprecated `tailor-sdk-skills` invocations with `tailor-sdk skills install`", + name: "tailor-sdk-skills → tailor skills add", + description: "Replace deprecated `tailor-sdk-skills` invocations with `tailor skills add`", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/sdk-skills-shim/scripts/transform.js", filePatterns: ["**/package.json", "**/*.{sh,bash,zsh,yml,yaml}", "**/*.md"], legacyPatterns: ["tailor-sdk-skills"], + examples: [ + { + lang: "sh", + before: "npx tailor-sdk-skills", + after: "tailor skills add", + }, + ], + prompt: [ + "The standalone tailor-sdk-skills binary is removed in v2; call the skills add", + "subcommand on the main tailor CLI instead. Replace any remaining", + "tailor-sdk-skills invocations the codemod did not rewrite with", + "`tailor skills add`.", + ].join("\n"), }, { id: "v2/principal-unify", - name: "Unify TailorUser/TailorActor/TailorInvoker → TailorPrincipal", + name: "Unify TailorUser/TailorActor/TailorActorType/TailorInvoker → TailorPrincipal", description: - "Rename TailorUser/TailorActor/TailorInvoker to TailorPrincipal, drop unauthenticatedTailorUser, and rename resolver body `user` to `caller`", + "Rename TailorUser/TailorActor/TailorActorType/TailorInvoker to TailorPrincipal, drop unauthenticatedTailorUser, rename resolver body `user` to `caller`, and rename TailorDB callback `user` to `invoker`", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_2, scriptPath: "v2/principal-unify/scripts/transform.js", - legacyPatterns: ["TailorUser", "TailorActor", "TailorInvoker", "unauthenticatedTailorUser"], + legacyPatterns: [ + "TailorUser", + "TailorActor", + "TailorActorType", + "TailorInvoker", + "unauthenticatedTailorUser", + ], + suspiciousPatterns: [ + "caller?.", + "context.user", + "context.invoker ?? context.user", + "ResolverContext", + ], + examples: [ + { + caption: "Type references unify under `TailorPrincipal`:", + before: 'import type { TailorUser } from "@tailor-platform/sdk";', + after: 'import type { TailorPrincipal } from "@tailor-platform/sdk";', + }, + { + caption: "The resolver body `user` becomes `caller`:", + before: "body: ({ input, user }) => user.id,", + after: "body: ({ input, caller }) => caller.id,", + }, + ], + prompt: [ + "Finish the cases the codemod left for manual migration:", + "- Rename user -> caller in resolver bodies the codemod skipped because a `caller`", + " binding already exists or renaming would shadow/collide with another value.", + "- Replace member-access on the removed unauthenticatedTailorUser (e.g.", + " unauthenticatedTailorUser.id); the codemod only replaced standalone references", + " with null and left member access to surface a type error.", + "- Review helper adapters that still accept or read `context.user`; v2 resolver", + " context uses nullable `caller` and `invoker`, so project-specific helper", + " semantics for anonymous callers and command invokers must be chosen explicitly.", + "- Review `caller?.` values passed to APIs that require non-null values. If the", + " resolver requires authentication, throw or otherwise narrow before the call;", + " if anonymous callers are allowed, keep the nullable flow explicit.", + "Use TailorPrincipal for the unified user/actor/invoker type.", + ].join("\n"), + }, + { + id: "v2/auth-attributes-rename", + name: "AttributeMap → Attributes", + description: + "Rename auth attribute module augmentation and related SDK type names from `AttributeMap` to `Attributes`", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/auth-attributes-rename/scripts/transform.js", + legacyPatterns: [ + "AttributeMap", + "interface AttributeMap", + "UserAttributeMap", + "InferredAttributeMap", + ], + examples: [ + { + caption: "Module augmentation uses `Attributes`:", + before: + 'declare module "@tailor-platform/sdk" {\n interface AttributeMap {\n role: string;\n }\n}', + after: + 'declare module "@tailor-platform/sdk" {\n interface Attributes {\n role: string;\n }\n}', + }, + ], + prompt: [ + "In Tailor SDK v2, the auth attribute type API is renamed from `AttributeMap`", + "to `Attributes`; related SDK types are renamed to `UserAttributes` and", + "`InferredAttributes`. The codemod rewrites SDK imports, re-exports,", + "namespace-qualified references, import() type references, and module", + "augmentations. Review any remaining matches manually and leave unrelated", + "local names or deploy/proto wire field names unchanged.", + ].join("\n"), }, { id: "v2/apply-to-deploy", name: "tailor-sdk apply → tailor-sdk deploy", description: - "Rewrite `tailor-sdk apply` invocations in package.json scripts, shell scripts, CI configs, and docs to the v2-recommended `tailor-sdk deploy` alias", + "Rewrite `tailor-sdk apply` invocations in package.json scripts, shell scripts, CI configs, and docs to the canonical v2 `tailor-sdk deploy` command", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/apply-to-deploy/scripts/transform.js", - filePatterns: ["**/package.json", "**/*.{sh,bash,zsh,yml,yaml}", "**/*.md"], + filePatterns: [ + "**/package.json", + "**/*.{sh,bash,zsh,yml,yaml}", + "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + "**/*.md", + ], + examples: [ + { + lang: "sh", + before: "tailor-sdk apply --profile prod", + after: "tailor-sdk deploy --profile prod", + }, + ], }, { id: "v2/cli-rename", - name: "v2 CLI rename (single-word commands)", + name: "v2 CLI rename", description: - "Rewrite `tailor-sdk crash-report` invocations to the v2 single-word `tailor-sdk crashreport` form across package.json scripts, shell scripts, CI configs, and docs", + "Rewrite `tailor-sdk crash-report` to `tailor-sdk crashreport` and `--machineuser` to `--machine-user` across package.json scripts, shell scripts, CI configs, and docs", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/cli-rename/scripts/transform.js", filePatterns: ["**/package.json", "**/*.{sh,bash,zsh,yml,yaml}", "**/*.md"], + legacyPatterns: ["tailor-sdk crash-report", "--machineuser"], + examples: [ + { + lang: "sh", + before: "tailor-sdk crash-report list\ntailor-sdk login --machineuser", + after: "tailor-sdk crashreport list\ntailor-sdk login --machine-user", + }, + ], + prompt: [ + "Apply the v2 CLI renames the codemod did not reach (only `tailor-sdk`-prefixed", + "invocations are rewritten): `tailor-sdk crash-report` -> `tailor-sdk crashreport`", + "and the `--machineuser` option -> `--machine-user`. Leave unrelated commands that", + "happen to use `--machineuser` alone.", + ].join("\n"), }, { - id: "v2/auth-invoker-unwrap", + id: "v2/env-var-rename", + name: "SDK environment variable rename", + description: + "Rewrite unambiguous removed SDK environment variable names to their v2 `TAILOR_*` names and flag generic names for manual review", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/env-var-rename/scripts/transform.js", + filePatterns: [ + "**/package.json", + "**/.env", + "**/.env.*", + "**/*.{env,sh,bash,zsh,yml,yaml,json,md}", + "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + ], + legacyPatterns: [ + "TAILOR_PLATFORM_SDK_CONFIG_PATH", + "TAILOR_PLATFORM_SDK_DTS_PATH", + "TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION", + "TAILOR_PLATFORM_SDK_BUILD_ONLY", + "TAILOR_SDK_OUTPUT_DIR", + "TAILOR_SDK_SKILLS_SOURCE", + "TAILOR_SDK_VERSION", + "PLATFORM_URL", + "PLATFORM_OAUTH2_CLIENT_ID", + "TAILOR_ENABLE_INLINE_SOURCEMAP", + "TAILOR_PLATFORM_QUERY_NEWLINE_ON_ENTER", + "LOG_LEVEL", + "TAILOR_TOKEN", + ], + sourceStringLegacyPatterns: ["PLATFORM_URL", "PLATFORM_OAUTH2_CLIENT_ID", "LOG_LEVEL"], + examples: [ + { + lang: "sh", + before: "TAILOR_PLATFORM_SDK_BUILD_ONLY=true tailor-sdk deploy", + after: "TAILOR_DEPLOY_BUILD_ONLY=true tailor-sdk deploy", + }, + { + before: "const token = process.env.TAILOR_TOKEN;", + after: "const token = process.env.TAILOR_PLATFORM_TOKEN;", + }, + ], + prompt: [ + "Review any remaining removed SDK environment variable names after the codemod", + "runs. The codemod intentionally leaves generic names such as `LOG_LEVEL`,", + "`PLATFORM_URL`, and `PLATFORM_OAUTH2_CLIENT_ID` for manual review because", + "they can configure non-SDK tools. Replace only actual SDK usages with their", + "v2 names. If a remaining match is an unrelated local identifier, fixture", + "label, or historical documentation that intentionally does not configure the", + "SDK, leave it unchanged.", + ].join("\n"), + }, + { + id: "v2/auth-invoker-call-unwrap", name: 'auth.invoker("name") → "name"', description: - 'Replace `auth.invoker("name")` calls with the bare `"name"` string and drop the `auth` import when no other reference remains. The `auth.invoker()` helper is deprecated in v2 because importing `auth` from `tailor.config.ts` into runtime files pulls Node-only modules into the bundle.', + 'Replace statically identified SDK `auth.invoker("name")` option values with the bare `"name"` string while preserving the `authInvoker` key for SDK versions before the option rename.', since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, + scriptPath: "v2/auth-invoker-call-unwrap/scripts/transform.js", + suspiciousPatterns: ["auth.invoker"], + reviewSupersededBy: ["v2/auth-invoker-unwrap"], + prompt: [ + "In Tailor SDK v2 the auth.invoker() helper is removed; an invoker is now the", + "machine user name passed directly as a string. The codemod already rewrote the", + 'statically identified SDK option form authInvoker: auth.invoker("name") to authInvoker: "name". These files still contain', + "auth.invoker(...) calls that need manual review.", + "", + "For each remaining auth.invoker() call:", + "1. Replace the whole call with only where the target option expects a", + " machine user name string; platform/runtime authInvoker payloads still expect", + " the object form.", + "2. Keep the authInvoker key when targeting SDK versions before the invoker", + " option rename; later v2 targets run a separate codemod for that key rename.", + "3. After removing every auth.invoker usage in a file, delete the now-unused auth", + " import (keeping it pulls Node-only config modules into runtime bundles); leave", + " the import if auth is still referenced elsewhere.", + "", + "Do not change behavior beyond the auth.invoker() removal.", + ].join("\n"), + examples: [ + { + before: 'createResolver({ authInvoker: auth.invoker("manager") });', + after: 'createResolver({ authInvoker: "manager" });', + }, + ], + }, + { + id: "v2/auth-invoker-unwrap", + name: 'auth.invoker("name") → invoker: "name"', + description: + 'Rename statically identified SDK `authInvoker` options to `invoker`, replace `auth.invoker("name")` there with the bare `"name"` string, and drop the `auth` import when no other reference remains. Ambiguous workflow `.start()` calls are left for manual review. The `auth.invoker()` helper is removed in v2 because importing `auth` from `tailor.config.ts` into runtime files pulls Node-only modules into the bundle.', + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_2, scriptPath: "v2/auth-invoker-unwrap/scripts/transform.js", - legacyPatterns: ["auth.invoker"], + suspiciousPatterns: [ + "auth.invoker", + "authInvoker:", + "authInvoker :", + "authInvoker?", + "{ authInvoker", + ", authInvoker", + "\n authInvoker", + "\n authInvoker", + "\n authInvoker", + '"authInvoker":', + '"authInvoker" :', + '"authInvoker"?', + "'authInvoker':", + "'authInvoker' :", + "'authInvoker'?", + ], + prompt: [ + "In Tailor SDK v2 the auth.invoker() helper is removed; an invoker is now the", + "machine user name passed directly as a string. The codemod already rewrote the", + 'statically identified SDK option form authInvoker: auth.invoker("name") to invoker: "name" and renamed supported authInvoker option keys. These files still contain', + "auth.invoker(...) calls or authInvoker keys that need manual review.", + "", + "For each remaining auth.invoker() call:", + "1. Replace the whole call with only where the target option expects a", + " machine user name string; platform/runtime authInvoker payloads still expect", + " the object form.", + "2. Rename remaining authInvoker option keys to invoker only for SDK resolver,", + " executor, workflow.start(), or startWorkflow() options. Keep platform/runtime", + " payload keys such as tailor.workflow.startWorkflow(..., { authInvoker: ... }).", + "3. After removing every auth.invoker usage in a file, delete the now-unused auth", + " import (keeping it pulls Node-only config modules into runtime bundles); leave", + " the import if auth is still referenced elsewhere.", + "", + "Do not change behavior beyond the SDK option rename and auth.invoker() removal.", + ].join("\n"), + examples: [ + { + before: 'createResolver({ invoker: auth.invoker("manager") });', + after: 'createResolver({ invoker: "manager" });', + }, + ], + }, + { + id: "v2/auth-connection-token-helper", + name: "auth.getConnectionToken() → runtime authconnection", + description: + "The deprecated `auth.getConnectionToken()` helper returned by `defineAuth()` is removed in v2. Use `authconnection.getConnectionToken(...)` from `@tailor-platform/sdk/runtime` in resolvers, executors, and workflows instead.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_2, + scriptPath: "v2/auth-connection-token-helper/scripts/transform.js", + filePatterns: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + examples: [ + { + before: + 'import { auth } from "../tailor.config";\n\nconst token = await auth.getConnectionToken("google");', + after: + 'import { authconnection } from "@tailor-platform/sdk/runtime";\n\nconst token = await authconnection.getConnectionToken("google");', + }, + ], + prompt: [ + "In Tailor SDK v2 the auth.getConnectionToken() helper returned by defineAuth()", + "is removed. Runtime code should call authconnection.getConnectionToken(...) from", + "@tailor-platform/sdk/runtime instead of importing auth from tailor.config.ts.", + "", + "For each getConnectionToken usage where is a defineAuth() result", + "imported from tailor.config.ts:", + "1. Replace .getConnectionToken() calls with", + " authconnection.getConnectionToken().", + "2. Update non-call references, including .getConnectionToken,", + ' ["getConnectionToken"], and destructuring from , to', + " reference authconnection instead.", + '3. Add or reuse `import { authconnection } from "@tailor-platform/sdk/runtime"`.', + "4. Remove the auth import from tailor.config.ts only when no other auth reference", + " remains in the file.", + "", + "Leave usages unchanged when the receiver is already the runtime authconnection", + "wrapper or global tailor.authconnection.", + ].join("\n"), + }, + { + id: "v2/runtime-subpath-namespace", + name: "Runtime subpath imports use namespace objects", + description: + "Rewrite `@tailor-platform/sdk/runtime/*` namespace-star and flat value imports to self-named namespace imports, and aggregate `file.deleteFile` calls to `file.delete`. `TailorContextAPI` and `TailorWorkflowAPI` now describe SDK wrappers; direct platform globals use `PlatformContextAPI` and `PlatformWorkflowAPI`.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_4, + scriptPath: "v2/runtime-subpath-namespace/scripts/transform.js", + filePatterns: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + legacyPatterns: [ + "@tailor-platform/sdk/runtime/iconv", + "@tailor-platform/sdk/runtime/secretmanager", + "@tailor-platform/sdk/runtime/authconnection", + "@tailor-platform/sdk/runtime/idp", + "@tailor-platform/sdk/runtime/workflow", + "@tailor-platform/sdk/runtime/context", + "@tailor-platform/sdk/runtime/file", + "@tailor-platform/sdk/runtime/aigateway", + ], + examples: [ + { + before: + 'import * as iconv from "@tailor-platform/sdk/runtime/iconv";\niconv.convert(value, "UTF-8", "Shift_JIS");', + after: + 'import { iconv } from "@tailor-platform/sdk/runtime/iconv";\niconv.convert(value, "UTF-8", "Shift_JIS");', + }, + { + before: + 'import { get } from "@tailor-platform/sdk/runtime/aigateway";\nconst gateway = await get("main");', + after: + 'import { aigateway } from "@tailor-platform/sdk/runtime/aigateway";\nconst gateway = await aigateway.get("main");', + }, + { + before: + 'import { file } from "@tailor-platform/sdk/runtime";\nawait file.deleteFile("ns", "Doc", "blob", "record-id");', + after: + 'import { file } from "@tailor-platform/sdk/runtime";\nawait file.delete("ns", "Doc", "blob", "record-id");', + }, + ], + prompt: [ + "In Tailor SDK v2, runtime subpath modules export only a self-named namespace", + "object (for example, `iconv` from `@tailor-platform/sdk/runtime/iconv`).", + "Default and flat value imports such as", + '`import { get } from "@tailor-platform/sdk/runtime/aigateway"` are removed.', + "The codemod rewrites straightforward namespace-star imports and flat named value", + "imports. It also rewrites direct `file.deleteFile` calls on the aggregate runtime", + "namespace to `file.delete`. Destructured aggregate `deleteFile` references require", + "manual migration. Review any remaining runtime imports manually, especially when", + "a local binding or nested scope shadows an imported value, or when", + "type-position namespace member references need explicit top-level type imports.", + "For direct platform globals, replace `TailorContextAPI` and `TailorWorkflowAPI`", + "type references with `PlatformContextAPI` and `PlatformWorkflowAPI` respectively.", + ].join("\n"), }, { id: "v2/tailordb-namespace", name: "Tailordb → tailordb (lowercase ambient namespace)", description: - "Rewrite references to the deprecated capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, `typeof Tailordb.Client`) to the new lowercase `tailordb.*` namespace re-published by the SDK in place of `@tailor-platform/function-types`.", + 'Rewrite references to the removed capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, `typeof Tailordb.Client`) to the lowercase `tailordb.*` namespace exposed by `@tailor-platform/sdk/runtime/globals`. Because v2 no longer activates ambient declarations automatically, each file that contains `tailordb.*` references after the rewrite must also add `import "@tailor-platform/sdk/runtime/globals"`.', since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/tailordb-namespace/scripts/transform.js", legacyPatterns: ["Tailordb."], + examples: [ + { + before: 'const command: Tailordb.CommandType = "SELECT";', + after: + 'import "@tailor-platform/sdk/runtime/globals";\nconst command: tailordb.CommandType = "SELECT";', + }, + ], + prompt: [ + "The capital-cased Tailordb ambient namespace is removed in v2; use the lowercase", + "tailordb.* namespace from @tailor-platform/sdk/runtime/globals. The codemod rewrites", + "the known members (QueryResult, CommandType, Client). Rewrite any other remaining", + "Tailordb.* reference to its tailordb.* equivalent (and confirm the member still", + "exists on the lowercase namespace).", + 'Also add `import "@tailor-platform/sdk/runtime/globals"` at the top of each file', + "that contains any tailordb.* type reference — v2 no longer activates ambient", + "declarations automatically on SDK import.", + ].join("\n"), + }, + { + id: "v2/db-type-to-table", + name: "db.type() → db.table()", + description: + "Rename TailorDB schema builder calls from `db.type()` to `db.table()`. TailorDB schema definitions now use table terminology in SDK projects.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_4, + scriptPath: "v2/db-type-to-table/scripts/transform.js", + filePatterns: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + legacyPatterns: ["db.type"], + examples: [ + { + before: + 'import { db } from "@tailor-platform/sdk";\n\nexport const user = db.type("User", {\n name: db.string(),\n});', + after: + 'import { db } from "@tailor-platform/sdk";\n\nexport const user = db.table("User", {\n name: db.string(),\n});', + }, + ], + prompt: [ + "In Tailor SDK v2, TailorDB schema definitions use db.table(...) instead of", + "db.type(...). The codemod rewrites member accesses on db imported from", + "@tailor-platform/sdk, including aliases such as `import { db as schema }`.", + "It flags destructured builder aliases such as `const { type } = db` and", + "local builder aliases such as `const schema = db`, `schema = db`, or", + "`function make(schema = db) { ... }` for manual review because the local", + "alias may require call-site renaming.", + "Review any remaining db.type references and rename SDK TailorDB schema builder", + "calls to db.table. Leave unrelated local objects with a .type() method unchanged.", + ].join("\n"), + }, + { + id: "v2/forward-relation-name", + name: "TailorDB forward relation names derive from field names", + description: + "Review TailorDB relations that omit `toward.as`. Their forward GraphQL field names now derive from the relation field name with a trailing `ID`, `Id`, or `id` removed, instead of from the target table name.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_5, + scriptPath: "v2/forward-relation-name/scripts/transform.js", + filePatterns: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + suspiciousPatterns: [ + /\.relation\b(?!\s*\()/, + /\{[^}\n]*\brelation\b[^}\n]*\}\s*=/, + /\[\s*["']relation["']\s*\]/, + ], + examples: [ + { + caption: "Preserve the v1 GraphQL field name by making it explicit:", + before: [ + "ownerId: db.uuid().relation({", + ' type: "n-1",', + " toward: { type: user },", + "}),", + ].join("\n"), + after: [ + "ownerId: db.uuid().relation({", + ' type: "n-1",', + ' toward: { type: user, as: "user" },', + "}),", + ].join("\n"), + }, + ], + prompt: [ + "Tailor SDK v2 derives a default forward GraphQL relation name from the source", + "field name by removing a trailing ID, Id, or id. V1 derived it from the target", + "table name. Review each reported non-self relation that omits toward.as.", + "", + "If consumers must keep using the v1 GraphQL field name, inspect the v1 schema and", + "copy that exact field name into toward.as. Otherwise, update GraphQL operations", + "and consumer code to use the new field-based name. No change is needed when the old", + "and new names are identical. Relations with a guaranteed non-empty toward.as,", + "self-relations, and keyOnly relations are unchanged. For an empty or dynamic", + "toward.as, determine whether its runtime value can be falsy; if so, treat the", + "relation as using the default name.", + "", + "A relation field without a trailing ID, Id, or id would default to its own scalar", + "field name and therefore conflict. Give that relation an explicit toward.as.", + ].join("\n"), + }, + { + id: "v2/execute-script-arg", + name: "executeScript arg JSON.stringify → value", + description: + "Unwrap `JSON.stringify(...)` passed as the `executeScript` `arg` option. In v2 `arg` takes a JSON-serializable value and is serialized internally, so a pre-stringified argument double-encodes.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_2, + scriptPath: "v2/execute-script-arg/scripts/transform.js", + filePatterns: ["**/*.{ts,tsx,mts,cts,mjs,cjs,js}"], + suspiciousPatterns: [ + ["executeScript", "JSON.stringify", /\barg\s*[:=]|["']arg["']\s*(?::|\]\s*[:=])/], + ], + prompt: [ + "In Tailor SDK v2 the executeScript() arg option takes a JSON-serializable value", + "and is serialized internally, so a pre-stringified argument double-encodes. The", + "codemod already rewrote the direct form arg: JSON.stringify(X) to arg: X. Review", + "the executeScript calls in these files for cases it could not rewrite — where the", + "arg value is reached indirectly, for example:", + "- a variable holding a JSON.stringify(...) result (const s = JSON.stringify(x); ... arg: s)", + "- JSON.stringify(x, null, 2) or another multi-argument form", + "- an options object built or spread dynamically", + "", + "For each such call, pass the underlying value directly as arg (drop the", + "JSON.stringify wrapper) so executeScript serializes it once. Leave calls that", + "already pass a plain value unchanged.", + ].join("\n"), + examples: [ + { + before: "await executeScript({ ...opts, arg: JSON.stringify({ a: 1 }) });", + after: "await executeScript({ ...opts, arg: { a: 1 } });", + }, + ], + }, + { + id: "v2/wait-point-rename", + name: "defineWaitPoint/defineWaitPoints → createWaitPoint/createWaitPoints", + description: + "Rename `defineWaitPoint` and `defineWaitPoints` to `createWaitPoint` and `createWaitPoints`. The functions create runtime instances with `.wait()` / `.resolve()` methods, so the `create*` prefix is used consistently.", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/wait-point-rename/scripts/transform.js", + legacyPatterns: ["defineWaitPoint", "defineWaitPoints"], + examples: [ + { + before: + 'import { defineWaitPoints } from "@tailor-platform/sdk";\n\nexport const { approval } = defineWaitPoints((define) => ({\n approval: define<{ message: string }, { approved: boolean }>(),\n}));', + after: + 'import { createWaitPoints } from "@tailor-platform/sdk";\n\nexport const { approval } = createWaitPoints((define) => ({\n approval: define<{ message: string }, { approved: boolean }>(),\n}));', + }, + ], + }, + { + id: "v2/workflow-trigger-rename", + name: "workflow.triggerWorkflow/triggerJobFunction/resumeWorkflow → startWorkflow/startJobFunction/resumeWorkflowExecution", + description: + "Rename tailor.workflow call sites from the pre-alignment triggerWorkflow/triggerJobFunction/resumeWorkflow names to the canonical startWorkflow/startJobFunction/resumeWorkflowExecution names, on both the ambient tailor.workflow global and a workflow value imported from @tailor-platform/sdk/runtime(/workflow). For a renamed triggerWorkflow call, also renames a literal `invoker` option key to `authInvoker` — startWorkflow's options expect the platform shape directly, unlike the removed triggerWorkflow wrapper, which converted invoker to authInvoker internally.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_6, + scriptPath: "v2/workflow-trigger-rename/scripts/transform.js", + filePatterns: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + legacyPatterns: ["triggerWorkflow", "triggerJobFunction", "resumeWorkflow"], + examples: [ + { + before: + 'import { workflow } from "@tailor-platform/sdk/runtime";\n\nawait workflow.triggerWorkflow("myWorkflow", { data: "value" });', + after: + 'import { workflow } from "@tailor-platform/sdk/runtime";\n\nawait workflow.startWorkflow("myWorkflow", { data: "value" });', + }, + { + caption: "A literal invoker option is renamed to authInvoker:", + before: + 'await workflow.triggerWorkflow("myWorkflow", { data: "value" }, { invoker: myInvoker });', + after: + 'await workflow.startWorkflow("myWorkflow", { data: "value" }, { authInvoker: myInvoker });', + }, + ], + prompt: [ + "The pre-alignment tailor.workflow names triggerWorkflow, triggerJobFunction, and", + "resumeWorkflow are removed from the SDK's type surface in v2; use the canonical", + "startWorkflow, startJobFunction, and resumeWorkflowExecution names instead. The", + "codemod rewrites direct member-access call sites on the ambient tailor.workflow", + "global and on a workflow value imported from @tailor-platform/sdk/runtime or", + "@tailor-platform/sdk/runtime/workflow (including aliased imports). It skips a", + "file entirely when a local declaration shadows the workflow import or the", + "ambient tailor name, to avoid rewriting an unrelated same-named value — review", + "those manually.", + "", + "For a renamed triggerWorkflow call, the codemod also renames a literal invoker", + "option key (including shorthand { invoker }) to authInvoker, since startWorkflow", + "expects the platform's authInvoker shape directly while triggerWorkflow's removed", + "wrapper converted invoker to authInvoker internally.", + "", + "Also review, and migrate by hand:", + "- Destructured references (e.g. const { triggerWorkflow } = workflow) — the", + " codemod only rewrites direct member-access calls.", + "- Imported TriggerWorkflowOptions / TriggerJobFunctionOptions types — rename", + " them to StartWorkflowOptions / StartJobFunctionOptions.", + "- An invoker option passed via a variable or spread (not a literal object) —", + " the codemod only inspects literal object arguments; rename the invoker key", + " to authInvoker in the options object's own definition.", + ].join("\n"), + }, + { + id: "v2/open-download-stream", + name: "openDownloadStream → downloadStream", + description: + "The deprecated `openDownloadStream` file-streaming API is removed in v2. Use `downloadStream` for streamed file downloads. The generated file utilities now emit `downloadFileStream` (which calls `downloadStream` and returns `FileDownloadStreamResponse`) instead of the removed `openFileDownloadStream` helper.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_2, + // No scriptPath: this is a codemod-less ("manual") migration. + filePatterns: ["**/*.{ts,tsx,mts,cts,mjs,cjs,js}"], + suspiciousPatterns: ["openDownloadStream", "openFileDownloadStream"], + examples: [ + { + before: "const res = await openDownloadStream(namespace, typeName, fieldName, recordId);", + after: "const res = await downloadStream(namespace, typeName, fieldName, recordId);", + }, + ], + prompt: [ + "The openDownloadStream file-streaming API is removed in v2. Replace every call to", + "openDownloadStream with downloadStream (same arguments). If you used the generated", + "openFileDownloadStream helper, switch to downloadFileStream, which calls", + "downloadStream and returns FileDownloadStreamResponse.", + ].join("\n"), + }, + { + id: "v2/runtime-globals-opt-in", + name: "Ambient runtime globals are opt-in", + description: + 'Importing `@tailor-platform/sdk` no longer activates the ambient `tailor.*` / `tailordb.*` global declarations. The codemod rewrites simple direct `new tailor.idp.Client(...)` calls to the typed `idp.Client` wrapper from `@tailor-platform/sdk/runtime`; broader runtime global usage remains review-only. Only if you relied on the ambient globals directly, add `import "@tailor-platform/sdk/runtime/globals"`. (The capital-cased `Tailordb.*` namespace is removed separately — see the `Tailordb → tailordb` codemod.)', + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_1, + scriptPath: "v2/runtime-globals-opt-in/scripts/transform.js", + filePatterns: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + suspiciousPatterns: [ + "tailor.context", + "tailor.iconv", + "tailor.idp", + "tailor.secretmanager", + "tailor.authconnection", + "tailor.workflow", + "tailor[", + "tailordb.Client", + "tailordb.CommandType", + "tailordb.QueryResult", + "tailordb.file", + "tailordb[", + "TailorDBFileError", + "TailorErrorItem", + "TailorErrorMessage", + "TailorErrors", + ], + sourceStringSuspiciousPatterns: [ + "new tailor.idp.Client", + /[=(:,[]\s*tailor\.idp\.Client\b/, + /(?:(?:=>|[=(:,<{]|\[)\s*|\b(?:return|await|typeof)\s+)tailor\.(?:authconnection|context|iconv|idp|secretmanager|workflow)(?:\.[A-Za-z_$][\w$]*)?\b/, + /\btailor\.(?:authconnection|context|iconv|idp|secretmanager|workflow)\.[A-Za-z_$][\w$]*\s*\(/, + "tailor[", + /\btailordb\.file\.[A-Za-z_$][\w$]*\s*\(/, + /(?:(?:=>|[=(:,<{]|\[)\s*|\b(?:return|await|typeof)\s+)tailordb\.file\b/, + /(?:\bnew\s+|(?:=>|[=(:,<{]|\[)\s*|\b(?:return|await|typeof)\s+)tailordb\.(?:Client|CommandType|QueryResult)\b/, + /<\s*tailordb\.(?:Client|CommandType|QueryResult)\b/, + "tailordb[", + /(?:\bnew\s+|\bthrow\s+|\binstanceof\s+)Tailor(?:DBFileError|Errors|ErrorMessage)\b/, + /(?:[:=<]\s*|\bas\s+)Tailor(?:DBFileError|Errors|ErrorMessage|ErrorItem)\b/, + /[:<]\s*TailorErrorItem\b/, + ], + examples: [ + { + caption: + "Preferred: switch to the typed wrappers from `@tailor-platform/sdk/runtime` and drop the ambient globals:", + before: "const client = new tailor.idp.Client();", + after: + 'import { idp } from "@tailor-platform/sdk/runtime";\nconst client = new idp.Client({ namespace: "my-namespace" });', + }, + { + caption: + "Fallback: only if you must keep referencing the bare `tailor.*` names, opt into the global declarations:", + before: "const client = new tailor.idp.Client();", + after: + 'import "@tailor-platform/sdk/runtime/globals";\nconst client = new tailor.idp.Client();', + }, + ], + prompt: [ + "The v2 SDK no longer enables ambient Tailor runtime globals from", + "`@tailor-platform/sdk`. For each flagged file that uses `tailor.*`,", + "`tailordb.*`, or Tailor runtime error globals, prefer migrating to the", + "typed wrappers from `@tailor-platform/sdk/runtime`. The codemod already", + "rewrites direct `new tailor.idp.Client(...)` calls to `new idp.Client(...)`", + "when the file has no conflicting `tailor` or `idp` binding. For any remaining", + "`tailor.idp.Client` references, either resolve the binding collision and use", + "`idp.Client`, or keep the ambient global deliberately.", + "", + "Only when the file must keep referencing the bare `tailor.*` names directly,", + "opt into the global declarations instead by adding one of these:", + '- per-file: `import "@tailor-platform/sdk/runtime/globals";`', + '- project-wide: `"types": ["@tailor-platform/sdk/runtime/globals"]` in', + " the relevant tsconfig compilerOptions", + "", + "Leave files unchanged when the matching name is local, imported from another", + "module, or appears only in comments or prose strings. Embedded code strings", + "that use runtime globals are review-only findings; do not insert imports inside", + "string literals.", + ].join("\n"), + }, + { + id: "v2/workflow-trigger-dispatch", + name: "Workflow job start() and start tests", + description: + "Workflow job `.start()` (previously `.trigger()`) now aligns with the platform runtime: it returns the job result directly instead of a Promise wrapper, and tests no longer run job bodies locally. Mock start responses with `mockWorkflow()` (`setJobHandler` / `enqueueResult`, assert via `startedJobs`), or use `runWorkflowLocally()` for a full-chain local run.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_1, + suspiciousPatterns: [".trigger("], + examples: [ + { + caption: "Tests must mock the workflow runtime instead of running bodies locally:", + before: 'const result = await orderJob.start({ id });\nexpect(result.status).toBe("done");', + after: + 'using wf = mockWorkflow();\nwf.setJobHandler((jobName) => (jobName === "order-job" ? { status: "done" } : null));\nconst result = await orderJob.start({ id });\nexpect(result.status).toBe("done");', + }, + ], + prompt: [ + "Workflow job .start() now uses the platform workflow runtime instead of running", + "the job body locally. In tests, acquire `using wf = mockWorkflow()` and provide", + "start responses (setJobHandler / enqueueResult), or use runWorkflowLocally() for a", + "full-chain local run; an unmocked start now throws. Outside tests, treat the", + "start result as the job output directly (no Promise wrapper to unwrap).", + ].join("\n"), + }, + { + id: "v2/workflow-start-rename", + name: "Workflow.trigger()/WorkflowJob.trigger() → .start()", + description: + "Rename `Workflow.trigger()` (returned by `createWorkflow()`) and `WorkflowJob.trigger()` (returned by `createWorkflowJob()`) to `.start()`, aligning the SDK's ergonomic verb with the platform's `start*` RPC vocabulary. No codemod ships for this rename: distinguishing a workflow/job `.trigger()` call from an unrelated object's own `.trigger()` method requires resolving the receiver back to a `createWorkflow`/`createWorkflowJob` result across files, which the SDK's own CLI bundler already does for build-time rewriting. Reusing that logic in a standalone script is a nontrivial lift, and — unlike the bundler, which fails loudly when it cannot rewrite a call — a codemod false positive would silently rewrite an unrelated `.trigger()` call with no error. For the call-site volume this rename typically involves, manual review guided by the prompt below is the safer trade-off.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_7, + filePatterns: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + suspiciousPatterns: [".trigger("], + examples: [ + { + before: [ + "const inventory = checkInventory.trigger({ orderId: input.orderId });", + 'const workflowRunId = await orderProcessingWorkflow.trigger(args, { invoker: "manager" });', + ].join("\n"), + after: [ + "const inventory = checkInventory.start({ orderId: input.orderId });", + 'const workflowRunId = await orderProcessingWorkflow.start(args, { invoker: "manager" });', + ].join("\n"), + }, + ], + prompt: [ + "In Tailor SDK v2, the ergonomic .trigger() method on a createWorkflow() or", + "createWorkflowJob() result is renamed to .start(). This is unrelated to the", + "separate tailor.workflow.triggerWorkflow/triggerJobFunction/resumeWorkflow removal", + "(see the workflow-trigger-rename codemod) — this rename targets the SDK's own", + "ergonomic wrapper, not the low-level platform call.", + "", + "For each flagged `.trigger(` call in these files:", + "1. Confirm the receiver is a workflow or job object — typically a local const", + " assigned from createWorkflow(...)/createWorkflowJob(...), a named import of one,", + " or the default import of a workflow module. Skip receivers that are unrelated", + " objects with their own .trigger() method (state machines, event emitters, etc.).", + "2. Rename the call from .trigger(...) to .start(...); the argument list is unchanged.", + "3. Update any mock/test code that reads WorkflowJob['trigger'] / Workflow['trigger']", + " as a type, or that mocks the ergonomic method via a wrapper — for example,", + " `wf.job(definition)` / `wf.workflow(definition)` from mockWorkflow() now return a", + " mock of the `.start` method.", + '4. Update prose/docs/comments that say "trigger the workflow/job" to "start" only', + " where they describe this SDK verb specifically, not unrelated event terminology.", + ].join("\n"), + }, + { + id: "v2/cli-token-keyring-storage", + name: "CLI tokens stored in the OS keyring", + description: + "CLI login tokens are stored in the OS keyring by default when available, falling back to the platform config file when it is not. No source change is required; re-login if you need tokens moved into the keyring.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_2, + notice: true, + }, + { + id: "v2/cli-users-by-subject", + name: "CLI users keyed by subject ID", + description: + "The CLI stores human users by their stable subject ID instead of email (email is kept for display). Legacy email-keyed entries are migrated automatically on the next login or token refresh. No source change is required.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_1, + notice: true, + }, + { + id: "v2/function-logs-content-hash", + name: "function logs require a content hash for source mapping", + description: + "`tailor function logs` maps stack traces against the function bundle only when the execution recorded a `contentHash`. Executions without one now show raw stack traces instead of mapped frames. No source change is required.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_1, + notice: true, + }, + { + id: "v2/rename-bin", + name: "tailor-sdk binary → tailor", + description: + "Rename the CLI binary from `tailor-sdk` to `tailor` in package.json scripts, shell scripts, CI workflows, source files, generated declaration comments, and documentation. Does not rename `.tailor-sdk` directory paths or the `create-tailor-sdk` scaffolding package. Note: v2 also changes the default generated output directory from `.tailor-sdk/` to `.tailor/` and the setup lock file from `.github/tailor-sdk.lock` to `.github/tailor.lock`. Run `mv .tailor-sdk .tailor` to migrate the generated output directory (preserves auth connection state and other local files). Run `git mv .github/tailor-sdk.lock .github/tailor.lock` if the old lock file exists; without it `tailor setup check` will treat all managed workflows as missing. Exact ignore-file entries for `.tailor-sdk/` are handled by the generated-output ignore codemod.", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/rename-bin/scripts/transform.js", + filePatterns: [ + "**/package.json", + "**/*.{sh,bash,zsh,yml,yaml}", + "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + "**/*.md", + ], + legacyPatterns: ["tailor-sdk"], + sourceStringLegacyPatterns: [ + RENAME_BIN_SOURCE_LEGACY_PATTERN, + RENAME_BIN_QUOTED_SOURCE_LEGACY_PATTERN, + RENAME_BIN_QUOTED_LEGACY_COMMAND_PATTERN, + ], + sourceTextLegacyPatterns: [ + RENAME_BIN_SOURCE_LEGACY_PATTERN, + RENAME_BIN_QUOTED_SOURCE_LEGACY_PATTERN, + RENAME_BIN_QUOTED_LEGACY_COMMAND_PATTERN, + ], + examples: [ + { + lang: "sh", + before: "tailor-sdk deploy\nnpx tailor-sdk@latest login", + after: "tailor deploy\nnpx @tailor-platform/sdk@latest login", + }, + ], + prompt: [ + "Rename any remaining `tailor-sdk` binary invocations to `tailor`. Only rewrite", + "the binary name — leave `.tailor-sdk` directory paths and `create-tailor-sdk`", + "package references unchanged.", + ].join("\n"), + }, + { + id: "v2/tailor-output-ignore-dir", + name: ".tailor-sdk ignore entries → .tailor", + description: + "Rewrite exact ignore-file entries for the v1 generated output directory from `.tailor-sdk` to the v2 `.tailor` directory. Other `.tailor-sdk` paths and prose are left unchanged.", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/tailor-output-ignore-dir/scripts/transform.js", + filePatterns: [ + "**/.gitignore", + "**/.npmignore", + "**/.dockerignore", + "**/gitignore", + "**/npmignore", + "**/dockerignore", + "**/_gitignore", + "**/_npmignore", + "**/_dockerignore", + "**/__dot__gitignore", + "**/__dot__npmignore", + "**/__dot__dockerignore", + "**/*.gitignore", + "**/*.npmignore", + "**/*.dockerignore", + ], + examples: [ + { + lang: "gitignore", + before: ".tailor-sdk/", + after: ".tailor/", + }, + ], + }, + { + id: "v2/tailordb-validate-simplify", + name: "ValidateFn simplification and type-level validate", + description: + "Field-level `ValidateFn` is simplified from `(args: { value, data, invoker }) => boolean` to `(args: { value }) => string | void` — the function now returns the error message directly instead of a separate `[fn, message]` tuple. The `ValidateConfig` tuple form and `Validators` record syntax on `db.type().validate()` are removed. Type-level validation uses `db.type().validate((args, issues) => void)` with `{ newRecord, oldRecord, invoker }` args and an `issues(field, message)` callback for cross-field rules.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_5, + suspiciousPatterns: ["ValidateConfig", "Validators<", "ValidatorsBase", ".validate("], + examples: [ + { + caption: + "Field-level validate: return an error message string instead of a boolean (tuple form removed):", + before: + '.validate(\n [({ value }) => value.length > 5, "Name must be longer than 5 characters"],\n)', + after: + '.validate(({ value }) =>\n value.length <= 5 ? "Name must be longer than 5 characters" : undefined,\n)', + }, + { + caption: + "Type-level validate: per-field record syntax replaced by a single function with `issues()` callback:", + before: + '.validate({\n name: [({ value }) => value.length > 5, "Name must be longer than 5"],\n})', + after: + '.validate(({ newRecord }, issues) => {\n if (newRecord.name && newRecord.name.length <= 5) {\n issues("name", "Name must be longer than 5");\n }\n})', + }, + ], + prompt: [ + "The v2 SDK simplifies field validation and introduces type-level validation.", + "", + "Field-level `.validate()` changes:", + "- Signature: `(args: { value, data, invoker }) => boolean` → `(args: { value }) => string | void`", + "- The function now returns the error message string directly (or undefined/void to pass)", + " instead of returning a boolean with the message in a separate tuple.", + "- The `[fn, errorMessage]` tuple form (`ValidateConfig`) is removed.", + "- `data` and `invoker` are no longer available in field-level validators.", + " Use type-level `.validate()` for cross-field or invoker-dependent rules.", + "", + "Type-level `.validate()` on `db.type()` changes:", + "- Old: `.validate({ fieldName: fn | [fn, msg] | fn[] })` (per-field record, `Validators` type)", + "- New: `.validate((args, issues) => void)` (single function, `TypeValidateFn` type)", + "- Args: `{ newRecord, oldRecord, invoker }` — `newRecord` is the record after hooks run", + "- Call `issues(field, message)` to report validation errors; `field` supports dotted paths", + "- Move per-field validators that need `data`/`invoker` to the type-level function", + "", + "For each remaining `ValidateConfig`, `Validators<`, or old-signature `.validate()` usage:", + "1. Rewrite field-level validators to return the error string directly", + "2. Move cross-field / invoker-dependent validators to the type-level function", + "3. Remove unused `ValidateConfig` / `Validators` type imports", + ].join("\n"), + }, + { + id: "v2/tailordb-hook-redesign", + name: "TailorDB hook redesign: field-level args and type-level hooks", + description: + "Field-level `HookFn` args change from `{ value, data, invoker }` to create `{ input, invoker, now }` / update `{ input, oldValue, invoker, now }` — `value` is renamed to `input`, matching the `input` arg on type-level hooks (same pre-hook data, narrowed to one field); `data` (the full record) is removed; `oldValue` (previous field value) is added for update hooks only; `now` (operation timestamp) is shared across all hooks. Type-level hooks on `db.type().hooks()` change from per-field mapping `{ fieldName: { create, update } }` (`Hooks`) to a single `{ create, update }` object (`TypeHook`) — create hooks take `{ input, invoker, now }`, update hooks take `{ input, oldRecord, invoker, now }` (oldRecord is always non-null). Both return partial field overrides.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_5, + suspiciousPatterns: ["Hooks<", "HookFn<", "Hook<", ".hooks("], + examples: [ + { + caption: + "Field-level hooks: `value` renamed to `input`, `data` replaced by `oldValue` and `now`; use `now` instead of `new Date()`:", + before: + "db.datetime().hooks({\n create: ({ value }) => value ?? new Date(),\n update: () => new Date(),\n})", + after: + "db.datetime().hooks({\n create: ({ input, now }) => input ?? now,\n update: ({ now }) => now,\n})", + }, + { + caption: "Type-level hooks: per-field mapping replaced by single create/update functions:", + before: + ".hooks({\n fullAddress: {\n create: ({ data }) => `${data.postalCode} ${data.address}`,\n update: ({ data }) => `${data.postalCode} ${data.address}`,\n },\n})", + after: + ".hooks({\n create: ({ input }) => ({\n fullAddress: `${input.postalCode} ${input.address}`,\n }),\n update: ({ input }) => ({\n fullAddress: `${input.postalCode} ${input.address}`,\n }),\n})", + }, + ], + prompt: [ + "The v2 SDK redesigns TailorDB hooks at both field and type levels.", + "", + "Field-level `.hooks()` on individual fields:", + "- Create args: `{ value, data, invoker }` → `{ input, invoker, now }` (no `oldValue`)", + "- Update args: `{ value, data, invoker }` → `{ input, oldValue, invoker, now }`", + "- `value` is renamed to `input`, matching the type-level hook's `input` arg — both are", + " the same pre-hook data, at different granularity", + "- `data` (full record) is removed; update hooks get `oldValue` (previous field value) instead", + "- `now` provides the operation timestamp — use `now` instead of `new Date()`", + "- If a field-level hook needs the full record (other fields), move it to a type-level hook", + "", + "Type-level `.hooks()` on `db.type()`:", + "- Old: `.hooks({ fieldName: { create: fn, update: fn } })` (per-field mapping, `Hooks` type)", + "- New: `.hooks({ create: fn, update: fn })` (single object, `TypeHook` type)", + "- Each function: `({ input, oldRecord, invoker, now }) => ({ fieldName: value, ... })`", + "- `input` is the pre-hook input (may have nullish values for optional/defaulted fields)", + "- Create hooks do not receive `oldRecord`; update hooks receive `oldRecord` (always non-null)", + "- Return an object with only the fields to override; unmentioned fields are unchanged", + "", + "Migration steps for each `.hooks()` call on a `db.type()`:", + "1. If the old per-field hooks only use `value`/`invoker` and don't reference `data`,", + " convert them to field-level hooks with the new args (`value` → `input`, plus `oldValue`, `now`)", + "2. If the old hooks reference `data` (cross-field access), convert to a type-level hook", + " using `input`/`oldRecord`", + "3. Remove unused `Hooks` / `HookFn<>` type imports", + ].join("\n"), + }, + { + id: "v2/generate-watch-flag", + name: "generate --watch flag removed", + description: + "Review and remove `tailor generate --watch` / `-W` invocations and the `watch` option on `GenerateOptions`. The flag, its dependency watcher, and the self-restart-on-change logic are removed; `generate` now always performs a single generation pass.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_6, + filePatterns: [ + "**/package.json", + "**/*.{sh,bash,zsh,yml,yaml}", + "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + "**/*.md", + ], + suspiciousPatterns: [ + /\bgenerate\b[^\n]*(?:--watch\b|\s-W\b)/, + [/\bgenerate\s*\(/, /\bwatch\s*:/], + ], + examples: [ + { + lang: "sh", + caption: "The --watch/-W flag no longer exists; re-run generate after each change:", + before: "tailor generate --watch", + after: "tailor generate", + }, + ], + prompt: [ + "Tailor SDK v2 removes the `generate --watch` (`-W`) flag along with the", + "dependency watcher and self-restart logic that powered it. `tailor generate`", + "now always runs a single generation pass and exits.", + "", + "For each flagged `tailor generate ... --watch` / `-W` invocation (package.json", + "scripts, shell scripts, CI configs, or docs), drop the flag and re-run", + "`tailor generate` after each change instead. If automatic regeneration on file", + "change is still needed, wrap the command with a general-purpose file watcher", + "(e.g. `chokidar-cli`, `nodemon`) at the project level.", + "", + "For programmatic use of `generate()` from `@tailor-platform/sdk/cli`, remove the", + "`watch` field from the `GenerateOptions` argument — the function now performs a", + "single generation pass and resolves once it completes.", + ].join("\n"), + }, + { + id: "v2/node-minimum-22-15-0", + name: "Node.js minimum version raised to 22.15.0", + description: + "v2 requires Node.js **22.15.0** or later. This is the first version that includes `module.registerHooks()`, which the SDK uses to register its TypeScript loader hook synchronously in the main thread. No source change is required; ensure your environment runs Node.js 22.15.0+.", + since: "1.0.0", + until: "2.0.0", + notice: true, + }, + { + id: "v2/remove-legacy-bundle-cleanup", + name: "Legacy bundle artifact cleanup removed from deploy", + description: + "`tailor deploy` no longer deletes on-disk bundle artifacts (`.entry.js` files, workflow-job bundles, and the `hooks-validate-scripts/` directory) left in the SDK output directory (`.tailor` by default) by SDK versions that predate the current in-memory bundling approach. Current bundlers no longer write these files. No source change is required; if such stale files remain from a very old SDK version, delete only those specific files/directories manually — do not delete the output directory itself, since it also holds deploy state (e.g. `secrets-state/`, `*.context.json`) that existing secrets and Auth Connections depend on.", + since: "1.0.0", + until: "2.0.0", + notice: true, }, ]; @@ -107,9 +1231,78 @@ export function resolveCodemodScript(scriptPath: string): string { return path.resolve(CODEMODS_ROOT, scriptPath); } +function reachesCodemodBoundary(toVersion: string, codemod: CodemodPackage): boolean { + if (gte(toVersion, codemod.until)) { + return true; + } + if ( + codemod.prereleaseUntil === undefined || + codemod.prereleaseUntil === V2_NEXT_PENDING || + !gte(toVersion, codemod.prereleaseUntil) + ) { + return false; + } + + const target = parse(toVersion)!; + const boundary = parse(codemod.until)!; + + return ( + target.prerelease.length > 0 && + target.major === boundary.major && + target.minor === boundary.minor && + target.patch === boundary.patch + ); +} + +function effectiveCodemodBoundary(codemod: CodemodPackage): string { + if (codemod.prereleaseUntil === V2_NEXT_PENDING) { + return codemod.until; + } + return codemod.prereleaseUntil ?? codemod.until; +} + +function assertCodemodBoundaries(codemods: CodemodPackage[]): void { + for (const codemod of codemods) { + const boundary = parse(codemod.until); + if (boundary === null) { + throw new Error( + `Codemod ${codemod.id} until must be a valid semver version: ${codemod.until}`, + ); + } + if (boundary.prerelease.length > 0) { + throw new Error(`Codemod ${codemod.id} until must be a stable version: ${codemod.until}`); + } + if (codemod.prereleaseUntil === undefined || codemod.prereleaseUntil === V2_NEXT_PENDING) { + continue; + } + + const prereleaseBoundary = parse(codemod.prereleaseUntil); + if (prereleaseBoundary === null) { + throw new Error( + `Codemod ${codemod.id} prereleaseUntil must be a valid semver version: ${codemod.prereleaseUntil}`, + ); + } + if (prereleaseBoundary.prerelease.length === 0) { + throw new Error( + `Codemod ${codemod.id} prereleaseUntil must be a prerelease version: ${codemod.prereleaseUntil}`, + ); + } + if ( + prereleaseBoundary.major !== boundary.major || + prereleaseBoundary.minor !== boundary.minor || + prereleaseBoundary.patch !== boundary.patch + ) { + throw new Error( + `Codemod ${codemod.id} prereleaseUntil must target the same version as until: ${codemod.prereleaseUntil}`, + ); + } + } +} + /** * Get codemod packages applicable for a version range. - * A codemod applies when: since <= fromVersion < until <= toVersion + * A codemod applies when: since <= fromVersion < boundary <= toVersion. + * A target prerelease reaches `until` only when the codemod declares `prereleaseUntil`. * @param fromVersion - Current SDK version (semver) * @param toVersion - Target SDK version (semver) * @returns Array of applicable codemod packages in registration order @@ -121,11 +1314,12 @@ export function getApplicableCodemods(fromVersion: string, toVersion: string): C if (!valid(toVersion)) { throw new Error(`Invalid toVersion: ${toVersion}`); } + assertCodemodBoundaries(allCodemods); return allCodemods.filter( (codemod) => gte(fromVersion, codemod.since) && - lt(fromVersion, codemod.until) && - gte(toVersion, codemod.until), + lt(fromVersion, effectiveCodemodBoundary(codemod)) && + reachesCodemodBoundary(toVersion, codemod), ); } diff --git a/packages/sdk-codemod/src/resolve-pending-boundaries.test.ts b/packages/sdk-codemod/src/resolve-pending-boundaries.test.ts new file mode 100644 index 000000000..f102df512 --- /dev/null +++ b/packages/sdk-codemod/src/resolve-pending-boundaries.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "vitest"; +import { resolvePendingBoundaries } from "./resolve-pending-boundaries"; + +const V2_NEXT_4_DECL = 'const V2_NEXT_4 = "2.0.0-next.4";'; +const PENDING_DECL = 'export const V2_NEXT_PENDING = "pending";'; + +function registrySource(...extraLines: string[]): string { + return [V2_NEXT_4_DECL, PENDING_DECL, ...extraLines].join("\n"); +} + +describe("resolvePendingBoundaries", () => { + test("is a no-op when no codemod references V2_NEXT_PENDING", () => { + const source = registrySource(" prereleaseUntil: V2_NEXT_4,"); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + expect(result).toEqual({ changed: false, source }); + }); + + test("inserts the resolved constant and rewrites usages", () => { + const source = registrySource(" prereleaseUntil: V2_NEXT_PENDING,"); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + expect(result.changed).toBe(true); + expect(result.constantName).toBe("V2_NEXT_5"); + expect(result.source).toContain('const V2_NEXT_5 = "2.0.0-next.5";'); + expect(result.source).toContain(PENDING_DECL); + expect(result.source).toContain("prereleaseUntil: V2_NEXT_5,"); + expect(result.source).not.toContain("V2_NEXT_PENDING,"); + }); + + test("rewrites every pending usage in the same resolution", () => { + const source = registrySource( + " prereleaseUntil: V2_NEXT_PENDING,", + " prereleaseUntil: V2_NEXT_PENDING,", + ); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + expect(result.source.match(/prereleaseUntil: V2_NEXT_5,/g)).toHaveLength(2); + expect(result.source.match(/const V2_NEXT_5 = /g)).toHaveLength(1); + }); + + test("reuses an already-declared constant instead of duplicating it", () => { + const source = [ + 'const V2_NEXT_5 = "2.0.0-next.5";', + PENDING_DECL, + " prereleaseUntil: V2_NEXT_PENDING,", + ].join("\n"); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + expect(result.source.match(/const V2_NEXT_5 = /g)).toHaveLength(1); + expect(result.source).toContain("prereleaseUntil: V2_NEXT_5,"); + }); + + test("throws when the existing constant points at a different version", () => { + const source = [ + 'const V2_NEXT_5 = "2.0.0-next.99";', + PENDING_DECL, + " prereleaseUntil: V2_NEXT_PENDING,", + ].join("\n"); + + expect(() => resolvePendingBoundaries(source, "2.0.0-next.5")).toThrow( + "V2_NEXT_5 is already declared as 2.0.0-next.99", + ); + }); + + test("is a no-op when the release skips straight to stable 2.0.0", () => { + const source = registrySource(" prereleaseUntil: V2_NEXT_PENDING,"); + const result = resolvePendingBoundaries(source, "2.0.0"); + + expect(result).toEqual({ changed: false, source }); + }); + + test("throws when the resolved version is not a next.N prerelease", () => { + const source = registrySource(" prereleaseUntil: V2_NEXT_PENDING,"); + + expect(() => resolvePendingBoundaries(source, "2.0.0-beta.1")).toThrow( + 'resolvedVersion must be a "2.0.0-next.N" prerelease', + ); + }); + + test("throws when the next identifier is not numeric", () => { + const source = registrySource(" prereleaseUntil: V2_NEXT_PENDING,"); + + expect(() => resolvePendingBoundaries(source, "2.0.0-next.foo")).toThrow( + 'resolvedVersion must be a "2.0.0-next.N" prerelease', + ); + }); + + test("throws when the resolved version is not on the 2.0.0 line", () => { + const source = registrySource(" prereleaseUntil: V2_NEXT_PENDING,"); + + expect(() => resolvePendingBoundaries(source, "2.1.0-next.1")).toThrow( + 'resolvedVersion must be a "2.0.0-next.N" prerelease', + ); + expect(() => resolvePendingBoundaries(source, "3.0.0-next.1")).toThrow( + 'resolvedVersion must be a "2.0.0-next.N" prerelease', + ); + }); + + test("throws when the resolved version is not valid semver", () => { + const source = registrySource(" prereleaseUntil: V2_NEXT_PENDING,"); + + expect(() => resolvePendingBoundaries(source, "not-a-version")).toThrow( + "resolvedVersion must be a valid semver version", + ); + }); + + test("inserts the new constant above a JSDoc block preceding V2_NEXT_PENDING", () => { + const source = [ + V2_NEXT_4_DECL, + "/**", + " * Sentinel for pending codemods.", + " */", + PENDING_DECL, + " prereleaseUntil: V2_NEXT_PENDING,", + ].join("\n"); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + const lines = result.source.split("\n"); + const constIndex = lines.indexOf('const V2_NEXT_5 = "2.0.0-next.5";'); + const jsdocIndex = lines.indexOf("/**"); + expect(constIndex).toBeGreaterThanOrEqual(0); + expect(constIndex).toBeLessThan(jsdocIndex); + expect(result.source).toContain( + ["/**", " * Sentinel for pending codemods.", " */", PENDING_DECL].join("\n"), + ); + }); + + test("tolerates non-canonical spacing around the usage and declaration", () => { + const source = [ + V2_NEXT_4_DECL, + 'export const V2_NEXT_PENDING="pending";', + " prereleaseUntil:V2_NEXT_PENDING ,", + ].join("\n"); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + expect(result.changed).toBe(true); + expect(result.source).toContain("prereleaseUntil: V2_NEXT_5,"); + }); + + test("tolerates a space before the colon in the usage", () => { + const source = registrySource(" prereleaseUntil : V2_NEXT_PENDING,"); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + expect(result.changed).toBe(true); + expect(result.source).toContain("prereleaseUntil: V2_NEXT_5,"); + }); + + test("tolerates extra whitespace between export and const", () => { + const source = [ + V2_NEXT_4_DECL, + 'export const V2_NEXT_PENDING = "pending";', + " prereleaseUntil: V2_NEXT_PENDING,", + ].join("\n"); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + expect(result.changed).toBe(true); + expect(result.source).toContain('const V2_NEXT_5 = "2.0.0-next.5";'); + }); + + test("tolerates an indented declaration and its JSDoc block", () => { + const source = [ + V2_NEXT_4_DECL, + " /**", + " * Sentinel for pending codemods.", + " */", + ' export const V2_NEXT_PENDING = "pending";', + " prereleaseUntil: V2_NEXT_PENDING,", + ].join("\n"); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + expect(result.changed).toBe(true); + expect(result.source).toContain('const V2_NEXT_5 = "2.0.0-next.5";'); + }); + + test("reuses an existing constant even when its spacing is non-canonical", () => { + const source = [ + 'const V2_NEXT_5="2.0.0-next.5";', + PENDING_DECL, + " prereleaseUntil: V2_NEXT_PENDING,", + ].join("\n"); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + expect(result.source.match(/V2_NEXT_5\s*=/g)).toHaveLength(1); + expect(result.source).toContain("prereleaseUntil: V2_NEXT_5,"); + }); + + test("tolerates CRLF line endings before the declaration", () => { + const source = [V2_NEXT_4_DECL, PENDING_DECL, " prereleaseUntil: V2_NEXT_PENDING,"].join( + "\r\n", + ); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + expect(result.changed).toBe(true); + expect(result.source).toContain('const V2_NEXT_5 = "2.0.0-next.5";'); + }); + + test("inserts the new constant using CRLF when the source uses CRLF throughout", () => { + const source = [V2_NEXT_4_DECL, PENDING_DECL, " prereleaseUntil: V2_NEXT_PENDING,"].join( + "\r\n", + ); + const result = resolvePendingBoundaries(source, "2.0.0-next.5"); + + expect(result.source).toContain('const V2_NEXT_5 = "2.0.0-next.5";\r\n'); + expect(result.source).not.toMatch(/[^\r]\n/); + }); +}); diff --git a/packages/sdk-codemod/src/resolve-pending-boundaries.ts b/packages/sdk-codemod/src/resolve-pending-boundaries.ts new file mode 100644 index 000000000..bae03d96f --- /dev/null +++ b/packages/sdk-codemod/src/resolve-pending-boundaries.ts @@ -0,0 +1,92 @@ +import { parse } from "semver"; + +// Tolerant of whitespace and CRLF, not just the exact oxfmt-formatted spacing this file +// happens to use today, so a manual edit or formatter change doesn't stop this from matching. +const PENDING_USAGE_PATTERN = /prereleaseUntil\s*:\s*V2_NEXT_PENDING\s*,/g; +// Includes a preceding JSDoc block (if any) so a new constant is inserted above it, +// not between the comment and `V2_NEXT_PENDING` where it would attach to the wrong export. +const PENDING_DECLARATION_PATTERN = + /(?:^[ \t]*\/\*\*[\s\S]*?\*\/\r?\n)?^[ \t]*export\s+const\s+V2_NEXT_PENDING\s*=\s*"pending";$/m; + +export interface ResolvePendingBoundariesResult { + /** Whether any `V2_NEXT_PENDING` usage was found and rewritten. */ + changed: boolean; + /** The constant name usages were rewritten to, when `changed` is true. */ + constantName?: string; + /** The rewritten registry.ts source. Identical to the input when `changed` is false. */ + source: string; +} + +/** + * Rewrite `prereleaseUntil: V2_NEXT_PENDING` usages in a registry.ts source to the + * concrete `V_NEXT_` constant for a resolved release version, inserting that + * constant's declaration if it doesn't already exist. A no-op when no usage is present. + * @param source - Current contents of registry.ts + * @param resolvedVersion - The version the release PR bumped `@tailor-platform/sdk` to (e.g. "2.0.0-next.5") + * @returns The (possibly) rewritten source and whether it changed + */ +export function resolvePendingBoundaries( + source: string, + resolvedVersion: string, +): ResolvePendingBoundariesResult { + if (!PENDING_USAGE_PATTERN.test(source)) { + return { changed: false, source }; + } + PENDING_USAGE_PATTERN.lastIndex = 0; + + const parsed = parse(resolvedVersion); + if (parsed === null) { + throw new Error(`resolvedVersion must be a valid semver version: ${resolvedVersion}`); + } + if ( + parsed.major === 2 && + parsed.minor === 0 && + parsed.patch === 0 && + parsed.prerelease.length === 0 + ) { + // The release skipped straight from a prerelease to stable 2.0.0 without ever + // resolving this sentinel. That's fine: reachesCodemodBoundary() in registry.ts + // already treats a pending codemod as reached once the target hits the stable + // `until` boundary, so no concrete V2_NEXT_N constant is needed for it to work. + return { changed: false, source }; + } + if ( + parsed.major !== 2 || + parsed.minor !== 0 || + parsed.patch !== 0 || + parsed.prerelease.length !== 2 || + parsed.prerelease[0] !== "next" || + typeof parsed.prerelease[1] !== "number" + ) { + throw new Error( + `resolvedVersion must be a "2.0.0-next.N" prerelease to resolve V2_NEXT_PENDING: ${resolvedVersion}`, + ); + } + const constantName = `V${parsed.major}_NEXT_${parsed.prerelease[1]}`; + + let updated = source; + const existingDeclaration = new RegExp(`^const\\s+${constantName}\\s*=\\s*"([^"]+)";$`, "m").exec( + source, + ); + if (existingDeclaration) { + if (existingDeclaration[1] !== resolvedVersion) { + throw new Error( + `${constantName} is already declared as ${existingDeclaration[1]}, which does not match the resolved version ${resolvedVersion}`, + ); + } + } else { + const declarationMatch = PENDING_DECLARATION_PATTERN.exec(updated); + if (declarationMatch === null) { + throw new Error("Could not find the V2_NEXT_PENDING declaration in registry.ts"); + } + const eol = source.includes("\r\n") ? "\r\n" : "\n"; + updated = updated.replace( + declarationMatch[0], + `const ${constantName} = "${resolvedVersion}";${eol}${declarationMatch[0]}`, + ); + } + + updated = updated.replace(PENDING_USAGE_PATTERN, `prereleaseUntil: ${constantName},`); + + return { changed: true, constantName, source: updated }; +} diff --git a/packages/sdk-codemod/src/runner-metadata.test.ts b/packages/sdk-codemod/src/runner-metadata.test.ts new file mode 100644 index 000000000..83df0d32c --- /dev/null +++ b/packages/sdk-codemod/src/runner-metadata.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; +import { createRunnerMetadata } from "./runner-metadata"; + +const packageInfo = { + packageName: "@tailor-platform/sdk-codemod", + packageVersion: "0.3.0-next.2", +}; + +describe("createRunnerMetadata", () => { + test("includes the exact package identity", () => { + const metadata = createRunnerMetadata({ + ...packageInfo, + packageRoot: "/repo/packages/sdk-codemod", + readGit: () => undefined, + realpath: (value) => value, + }); + + expect(metadata).toEqual({ + packageName: "@tailor-platform/sdk-codemod", + packageVersion: "0.3.0-next.2", + }); + }); + + test("includes the branch commit and local build command for a source checkout", () => { + const metadata = createRunnerMetadata({ + ...packageInfo, + packageRoot: "/repo/packages/sdk-codemod", + readGit: (_cwd, args) => { + if (args.join(" ") === "rev-parse --show-toplevel") return "/repo"; + if (args.join(" ") === "rev-parse --verify HEAD") return "abc123"; + return undefined; + }, + realpath: (value) => value, + }); + + expect(metadata).toEqual({ + packageName: "@tailor-platform/sdk-codemod", + packageVersion: "0.3.0-next.2", + gitCommit: "abc123", + localBuildCommand: "pnpm --dir packages/sdk-codemod build", + }); + }); + + test("does not report the consuming project's commit for an installed package", () => { + const metadata = createRunnerMetadata({ + ...packageInfo, + packageRoot: "/project/node_modules/@tailor-platform/sdk-codemod", + readGit: (_cwd, args) => { + if (args.join(" ") === "rev-parse --show-toplevel") return "/project"; + if (args.join(" ") === "rev-parse --verify HEAD") return "project-commit"; + return undefined; + }, + realpath: (value) => value, + }); + + expect(metadata).toEqual({ + packageName: "@tailor-platform/sdk-codemod", + packageVersion: "0.3.0-next.2", + }); + }); +}); diff --git a/packages/sdk-codemod/src/runner-metadata.ts b/packages/sdk-codemod/src/runner-metadata.ts new file mode 100644 index 000000000..d77901ccb --- /dev/null +++ b/packages/sdk-codemod/src/runner-metadata.ts @@ -0,0 +1,68 @@ +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import * as path from "pathe"; + +export interface RunnerMetadata { + packageName: string; + packageVersion: string; + gitCommit?: string; + localBuildCommand?: string; +} + +interface CreateRunnerMetadataOptions { + packageName: string; + packageVersion: string; + packageRoot: string; + readGit?: (cwd: string, args: string[]) => string | undefined; + realpath?: (value: string) => string; +} + +const SOURCE_PACKAGE_PATH = "packages/sdk-codemod"; +const LOCAL_BUILD_COMMAND = "pnpm --dir packages/sdk-codemod build"; + +export function createRunnerMetadata({ + packageName, + packageVersion, + packageRoot, + readGit = readGitOutput, + realpath = safeRealpath, +}: CreateRunnerMetadataOptions): RunnerMetadata { + const metadata: RunnerMetadata = { packageName, packageVersion }; + const gitRoot = readGit(packageRoot, ["rev-parse", "--show-toplevel"]); + if (!gitRoot) return metadata; + + const packagePathFromRoot = path + .normalize(path.relative(realpath(gitRoot), realpath(packageRoot))) + .replaceAll("\\", "/"); + + if (packagePathFromRoot !== SOURCE_PACKAGE_PATH) return metadata; + + const gitCommit = readGit(packageRoot, ["rev-parse", "--verify", "HEAD"]); + if (!gitCommit) return metadata; + + return { + ...metadata, + gitCommit, + localBuildCommand: LOCAL_BUILD_COMMAND, + }; +} + +function readGitOutput(cwd: string, args: string[]): string | undefined { + try { + const output = execFileSync("git", ["-C", cwd, ...args], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return output || undefined; + } catch { + return undefined; + } +} + +function safeRealpath(value: string): string { + try { + return realpathSync(value); + } catch { + return path.resolve(value); + } +} diff --git a/packages/sdk-codemod/src/runner.test.ts b/packages/sdk-codemod/src/runner.test.ts index 9e658cf58..cbc5bdba0 100644 --- a/packages/sdk-codemod/src/runner.test.ts +++ b/packages/sdk-codemod/src/runner.test.ts @@ -2,9 +2,20 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "pathe"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { allCodemods } from "./registry"; import { runCodemods } from "./runner"; import type { CodemodPackage } from "./types"; +type TestCodemodExtra = Pick< + CodemodPackage, + | "sourceStringLegacyPatterns" + | "sourceTextLegacyPatterns" + | "suspiciousPatterns" + | "sourceStringSuspiciousPatterns" + | "prompt" + | "reviewSupersededBy" +>; + /** * Create a temporary directory with a test file for codemod testing. * @param fileName - Name of the test file @@ -21,7 +32,13 @@ async function createTestProject( return { tmpDir, filePath }; } -function makeCodemod(id: string, scriptPath: string, filePatterns?: string[]): CodemodPackage { +function makeCodemod( + id: string, + scriptPath?: string, + filePatterns?: string[], + legacyPatterns?: Array, + extra?: TestCodemodExtra, +): CodemodPackage { return { id, name: id, @@ -30,6 +47,8 @@ function makeCodemod(id: string, scriptPath: string, filePatterns?: string[]): C until: "2.0.0", scriptPath, filePatterns, + legacyPatterns, + ...extra, }; } @@ -164,6 +183,7 @@ describe("runCodemods", () => { describe("filePatterns filtering", () => { const transformPath = path.join(os.tmpdir(), "transform-upper.ts"); + const throwingTransformPath = path.join(os.tmpdir(), "transform-throw.ts"); beforeEach(async () => { await fs.promises.writeFile( @@ -173,10 +193,18 @@ describe("runCodemods", () => { }`, "utf-8", ); + await fs.promises.writeFile( + throwingTransformPath, + `export default function transform() { + throw new Error("nonmatching transform should not run"); + }`, + "utf-8", + ); }); afterEach(async () => { await fs.promises.rm(transformPath, { force: true }); + await fs.promises.rm(throwingTransformPath, { force: true }); }); test("should only apply transform to files matching filePatterns", async () => { @@ -184,6 +212,7 @@ describe("runCodemods", () => { tmpDir = dir; await fs.promises.writeFile(path.join(dir, "config.ts"), "hello", "utf-8"); await fs.promises.writeFile(path.join(dir, "data.json"), "world", "utf-8"); + using readFileSpy = vi.spyOn(fs.promises, "readFile"); const result = await runCodemods( [ @@ -199,6 +228,7 @@ describe("runCodemods", () => { // Only JSON file should be modified expect(result.filesModified).toHaveLength(1); expect(result.filesModified[0]).toContain("data.json"); + expect(readFileSpy).not.toHaveBeenCalledWith(path.join(dir, "config.ts"), "utf-8"); // TS file should be unchanged const tsContent = await fs.promises.readFile(path.join(dir, "config.ts"), "utf-8"); @@ -208,5 +238,1645 @@ describe("runCodemods", () => { const jsonContent = await fs.promises.readFile(path.join(dir, "data.json"), "utf-8"); expect(jsonContent).toBe("WORLD"); }); + + test("should not run transforms whose filePatterns do not match a matched file", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-pattern-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "data.json"), "world", "utf-8"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/upper", transformPath, ["**/*.json"]), + scriptPath: transformPath, + }, + { + codemod: makeCodemod("test/throw", throwingTransformPath, ["**/*.ts"]), + scriptPath: throwingTransformPath, + }, + ], + dir, + false, + ); + + expect(result.filesModified).toEqual([path.join(dir, "data.json")]); + await expect(fs.promises.readFile(path.join(dir, "data.json"), "utf-8")).resolves.toBe( + "WORLD", + ); + }); + + test("should apply transforms to matching files under dot directories", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-dot-test-")); + tmpDir = dir; + const workflowPath = path.join(dir, ".github/workflows/test.yml"); + await fs.promises.mkdir(path.dirname(workflowPath), { recursive: true }); + await fs.promises.writeFile(workflowPath, "hello", "utf-8"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/upper", transformPath, ["**/*.yml"]), + scriptPath: transformPath, + }, + ], + dir, + false, + ); + + expect(result.filesModified).toEqual([workflowPath]); + await expect(fs.promises.readFile(workflowPath, "utf-8")).resolves.toBe("HELLO"); + }); + + test("should skip unapproved tool dot directories", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-dot-test-")); + tmpDir = dir; + const workflowPath = path.join(dir, ".github/workflows/test.yml"); + const agentPackagePath = path.join(dir, ".agent/worktrees/demo/package.json"); + const nextYamlPath = path.join(dir, ".next/cache/workflow.yml"); + await fs.promises.mkdir(path.dirname(workflowPath), { recursive: true }); + await fs.promises.mkdir(path.dirname(agentPackagePath), { recursive: true }); + await fs.promises.mkdir(path.dirname(nextYamlPath), { recursive: true }); + await fs.promises.writeFile(workflowPath, "hello", "utf-8"); + await fs.promises.writeFile( + agentPackagePath, + '{"scripts":{"deploy":"tailor apply"}}', + "utf-8", + ); + await fs.promises.writeFile(nextYamlPath, "hello", "utf-8"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/upper", transformPath, ["**/*.yml", "**/package.json"]), + scriptPath: transformPath, + }, + ], + dir, + false, + ); + + expect(result.filesModified).toEqual([workflowPath]); + await expect(fs.promises.readFile(workflowPath, "utf-8")).resolves.toBe("HELLO"); + await expect(fs.promises.readFile(agentPackagePath, "utf-8")).resolves.toBe( + '{"scripts":{"deploy":"tailor apply"}}', + ); + await expect(fs.promises.readFile(nextYamlPath, "utf-8")).resolves.toBe("hello"); + }); + }); + + describe("legacy pattern warnings", () => { + const partialTransformPath = path.join(os.tmpdir(), "transform-partial.ts"); + + beforeEach(async () => { + await fs.promises.writeFile( + partialTransformPath, + `export default function transform(source) { + return source.replaceAll("tailor crash-report", "tailor crashreport"); + }`, + "utf-8", + ); + }); + + afterEach(async () => { + await fs.promises.rm(partialTransformPath, { force: true }); + }); + + test("warns when legacy patterns remain after a partial migration", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "README.md"), + "Run `tailor crash-report list`.\nRun tailor login --machineuser.\n", + "utf-8", + ); + + using _stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/partial", + partialTransformPath, + ["**/*.md"], + ["tailor crash-report", "--machineuser"], + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.filesModified).toEqual([path.join(dir, "README.md")]); + expect(result.warnings).toEqual([ + "README.md: contains --machineuser but was not migrated automatically (rule: test/partial). Manual migration may be needed.", + ]); + }); + + test("ignores source comments, strings, and identifier substrings for legacy warnings", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "createContext.ts"), + [ + "// Matches SDK's unauthenticatedTailorUser.id", + 'const note = "TailorUser";', + "const unauthenticatedTailorUserId = caller?.id;", + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/principal", + partialTransformPath, + ["**/*.ts"], + ["TailorUser", "unauthenticatedTailorUser"], + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("ignores JSX text for legacy warnings in JavaScript files", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "docs.js"), + "export const docs =

package tailor-sdk is installed

;", + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", undefined, ["**/*.js"], ["tailor-sdk"]), + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("keeps legacy warnings for source identifiers", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "resolver.ts"), + [ + 'import type { TailorUser } from "@tailor-platform/sdk";', + "const fallback = unauthenticatedTailorUser;", + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/principal", + partialTransformPath, + ["**/*.ts"], + ["TailorUser", "unauthenticatedTailorUser"], + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "resolver.ts: contains TailorUser, unauthenticatedTailorUser but was not migrated automatically (rule: test/principal). Manual migration may be needed.", + ]); + }); + + test("keeps legacy warnings for process.env bracket keys in source files", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "env.ts"), + [ + 'const platformUrl = process.env["PLATFORM_URL"];', + "const logLevel = process.env[`LOG_LEVEL`];", + 'const unrelated = "LOG_LEVEL";', + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/env", + partialTransformPath, + ["**/*.ts"], + ["PLATFORM_URL", "LOG_LEVEL"], + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "env.ts: contains PLATFORM_URL, LOG_LEVEL but was not migrated automatically (rule: test/env). Manual migration may be needed.", + ]); + }); + + test("keeps opt-in legacy warnings for source string fragments", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "env.ts"), + [ + 'import { execSync } from "node:child_process";', + 'execSync("PLATFORM_URL=https://api.test LOG_LEVEL=DEBUG tailor-sdk login");', + "// PLATFORM_URL in a comment stays ignored.", + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/env", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: ["PLATFORM_URL", "LOG_LEVEL"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "env.ts: contains PLATFORM_URL, LOG_LEVEL but was not migrated automatically (rule: test/env). Manual migration may be needed.", + ]); + }); + + test("keeps source string residual checks inside each literal", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + [ + 'const packageName = "tailor-sdk";', + 'const command = "deploy";', + 'spawn("tailor", ["--arg", "tailor-sdk deploy", "deploy"]);', + 'spawn("npx", ["@tailor-platform/sdk", "--arg", "tailor-sdk deploy", "deploy"]);', + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: [/tailor-sdk(?=\s+deploy)/], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("keeps generic source string residual checks out of comments", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "env.ts"), + "// PLATFORM_URL is documented here\nconst value = 1;", + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/env", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: ["PLATFORM_URL"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("keeps escaped quoted Tailor values out of rename-bin residual warnings", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + 'const command = "tailor --arg \\"tailor-sdk deploy\\" deploy";', + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("keeps split Tailor option values out of rename-bin residual warnings", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + 'spawn("tailor", ["tailordb", "migration", "generate", "--name", "tailor-sdk deploy"]);', + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("keeps shim and path Tailor option values out of rename-bin residual warnings", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + [ + 'spawn("tailor.cmd", ["--arg", "tailor-sdk deploy", "deploy"]);', + 'spawn("./node_modules/.bin/tailor", ["--arg", "tailor-sdk deploy", "deploy"]);', + ].join("\n"), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("warns for split argv rename-bin residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + [ + 'spawn("tailor-sdk", ["apply"]);', + 'spawn("tailor-sdk", ["crash-report", "list"]);', + 'spawn("npx", ["-p", "@tailor-platform/sdk", "tailor-sdk", "crash-report"]);', + ].join("\n"), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for variable-backed rename-bin source residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + ['const bin = "tailor-sdk";', 'spawn(bin, ["deploy"]);'].join("\n"), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for source comment and JSX rename-bin residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.tsx"), + ["// tailor-sdk apply", "const docs = tailor-sdk crash-report list;"].join( + "\n", + ), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.tsx"], [], { + sourceTextLegacyPatterns: renameBin?.sourceTextLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.tsx: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for quoted shell rename-bin residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + [ + 'const note = "unrelated";', + "const command = 'bash -lc \"tailor-sdk crash-report list\"';", + ].join("\n"), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for quoted legacy CLI residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + "const message = 'Run \"tailor-sdk crash-report list\" manually';", + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for source command residuals with shadowed aliases", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + [ + 'const bin = "tailor-sdk";', + 'spawn(bin, ["apply"]);', + "function shadow() {", + ' const bin = "tailor";', + " return bin;", + "}", + ].join("\n"), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for source command residuals before later fragments", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + ["const command = `${runner} tailor-sdk ${subcommand}`;", 'const later = "deploy";'].join( + "\n", + ), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("keeps multiple-spaced Tailor option values out of rename-bin residual warnings", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + 'const command = "tailor --name tailor-sdk deploy";', + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("warns for dynamic template rename-bin residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + "const command = `${runner} tailor-sdk ${subcommand}`;", + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for dynamic package flag rename-bin residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + "const command = `npx -p ${pkg} tailor-sdk login`;", + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("keeps source string residual checks in non-Tailor option values", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "env.ts"), + 'spawn("node", ["-e", "process.env.LOG_LEVEL"]);', + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/env", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: ["LOG_LEVEL"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "env.ts: contains LOG_LEVEL but was not migrated automatically (rule: test/env). Manual migration may be needed.", + ]); + }); + + test("keeps non-rename-bin source string residual checks in Tailor option values", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "env.ts"), + 'spawn("tailor", ["--arg", "LOG_LEVEL=debug", "deploy"]);', + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/env", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: ["LOG_LEVEL"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "env.ts: contains LOG_LEVEL but was not migrated automatically (rule: test/env). Manual migration may be needed.", + ]); + }); + + test("flags files matching a suspicious pattern for LLM review", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "executeScript({ arg: payload });\n"); + await fs.promises.writeFile(path.join(dir, "b.ts"), "const x = 1;\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["a.ts"], + }, + ]); + }); + + test("flags runtime subpath imports left after conservative skips", async () => { + const codemod = allCodemods.find((entry) => entry.id === "v2/runtime-subpath-namespace"); + if (!codemod?.scriptPath) throw new Error("runtime subpath codemod missing script"); + const scriptPath = path.resolve( + __dirname, + "../codemods", + codemod.scriptPath.replace(/\.js$/, ".ts"), + ); + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-runtime-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "exports.ts"), + [ + 'import { get } from "@tailor-platform/sdk/runtime/aigateway";', + "", + "export { get };", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "reexport.ts"), + ['export { get } from "@tailor-platform/sdk/runtime/aigateway";', ""].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "reexport-all.ts"), + ['export * from "@tailor-platform/sdk/runtime/aigateway";', ""].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "reexport-namespace.ts"), + ['export * as aigateway from "@tailor-platform/sdk/runtime/aigateway";', ""].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "dynamic.ts"), + [ + 'type ClientRef = import("@tailor-platform/sdk/runtime/idp").Client;', + 'const getGateway = (await import("@tailor-platform/sdk/runtime/aigateway")).get;', + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "dynamic-template.ts"), + [ + "const getGateway = (await import(`@tailor-platform/sdk/runtime/aigateway`)).get;", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "dynamic-const.ts"), + [ + 'const runtimeModule = "@tailor-platform/sdk/runtime/aigateway";', + "const getGateway = (await import(runtimeModule)).get;", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "type-reference.ts"), + [ + 'import type { Client } from "@tailor-platform/sdk/runtime/idp";', + "", + "type RuntimeClient = Client;", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "namespace-type.ts"), + [ + 'import * as idp from "@tailor-platform/sdk/runtime/idp";', + "", + "type RuntimeConfig = idp.ClientConfig;", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "require.cjs"), + [ + 'const { get } = require("@tailor-platform/sdk/runtime/aigateway");', + "module.exports = get;", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "import-equals.cts"), + [ + 'import iconv = require("@tailor-platform/sdk/runtime/iconv");', + "", + "export const encode = iconv.encode;", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "default-import.ts"), + [ + 'import iconv from "@tailor-platform/sdk/runtime/iconv";', + "", + 'iconv.convert("a", "UTF-8", "Shift_JIS");', + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "aggregate-destructure.ts"), + [ + 'import { file as runtimeFile } from "@tailor-platform/sdk/runtime";', + "", + "const { deleteFile } = runtimeFile;", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "shadow-only.ts"), + [ + 'import { file as runtimeFile } from "@tailor-platform/sdk/runtime";', + "", + "function remove(runtimeFile: { deleteFile(): void }) {", + " runtimeFile.deleteFile();", + "}", + "", + ].join("\n"), + ); + + const result = await runCodemods([{ codemod, scriptPath }], dir, true); + + expect(result.changed).toBe(false); + expect(result.llmReviews).toEqual([ + { + codemodId: "v2/runtime-subpath-namespace", + prompt: codemod.prompt, + files: [ + "aggregate-destructure.ts", + "default-import.ts", + "dynamic-const.ts", + "dynamic-template.ts", + "dynamic.ts", + "exports.ts", + "import-equals.cts", + "namespace-type.ts", + "reexport-all.ts", + "reexport-namespace.ts", + "reexport.ts", + "require.cjs", + "type-reference.ts", + ], + findings: [ + expect.objectContaining({ + file: "aggregate-destructure.ts", + line: 3, + excerpt: "{ deleteFile } = runtimeFile", + }), + expect.objectContaining({ + file: "default-import.ts", + line: 1, + excerpt: 'import iconv from "@tailor-platform/sdk/runtime/iconv";', + }), + expect.objectContaining({ + file: "dynamic-const.ts", + line: 2, + excerpt: "(await import(runtimeModule)).get", + }), + expect.objectContaining({ + file: "dynamic-template.ts", + line: 1, + excerpt: "(await import(`@tailor-platform/sdk/runtime/aigateway`)).get", + }), + expect.objectContaining({ + file: "dynamic.ts", + line: 1, + excerpt: 'import("@tailor-platform/sdk/runtime/idp").Client', + }), + expect.objectContaining({ + file: "dynamic.ts", + line: 2, + excerpt: '(await import("@tailor-platform/sdk/runtime/aigateway")).get', + }), + expect.objectContaining({ + file: "exports.ts", + line: 1, + excerpt: 'import { get } from "@tailor-platform/sdk/runtime/aigateway";', + }), + expect.objectContaining({ + file: "import-equals.cts", + line: 1, + excerpt: 'import iconv = require("@tailor-platform/sdk/runtime/iconv");', + }), + expect.objectContaining({ + file: "namespace-type.ts", + line: 1, + excerpt: 'import * as idp from "@tailor-platform/sdk/runtime/idp";', + }), + expect.objectContaining({ + file: "reexport-all.ts", + line: 1, + excerpt: 'export * from "@tailor-platform/sdk/runtime/aigateway";', + }), + expect.objectContaining({ + file: "reexport-namespace.ts", + line: 1, + excerpt: 'export * as aigateway from "@tailor-platform/sdk/runtime/aigateway";', + }), + expect.objectContaining({ + file: "reexport.ts", + line: 1, + excerpt: 'export { get } from "@tailor-platform/sdk/runtime/aigateway";', + }), + expect.objectContaining({ + file: "require.cjs", + line: 1, + excerpt: 'require("@tailor-platform/sdk/runtime/aigateway")', + }), + expect.objectContaining({ + file: "type-reference.ts", + line: 1, + excerpt: 'import type { Client } from "@tailor-platform/sdk/runtime/idp";', + }), + ], + }, + ]); + }); + + test("flags unresolved auth connection token helper usages for LLM review", async () => { + const codemod = allCodemods.find((entry) => entry.id === "v2/auth-connection-token-helper"); + if (!codemod?.scriptPath) throw new Error("auth connection token codemod missing script"); + const scriptPath = path.resolve( + __dirname, + "../codemods", + codemod.scriptPath.replace(/\.js$/, ".ts"), + ); + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-auth-token-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "migrated.ts"), + [ + 'import { auth } from "../tailor.config";', + "", + "export async function run() {", + ' return auth.getConnectionToken("google");', + "}", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "default-import.ts"), + [ + 'import config from "../tailor.config";', + "", + "export async function run() {", + ' return config.auth.getConnectionToken("google");', + "}", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "reexported-config.ts"), + [ + 'import { auth } from "../app-config";', + "", + "export async function run() {", + ' return auth.getConnectionToken("google");', + "}", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "computed.ts"), + [ + 'import { auth } from "../tailor.config";', + "", + 'export const token = await auth["getConnectionToken"]("google");', + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "destructure.ts"), + [ + 'import { auth } from "../tailor.config";', + "", + "export const { getConnectionToken } = auth;", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "cjs-require.js"), + [ + 'const { auth } = require("../tailor.config");', + "", + 'exports.token = auth.getConnectionToken("google");', + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "collision.ts"), + [ + 'import { auth } from "../tailor.config";', + "", + "const authconnection = createClient();", + "", + "export async function run() {", + ' return auth.getConnectionToken("google");', + "}", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "shadowed.ts"), + [ + 'import { auth } from "../tailor.config";', + "", + "export async function run(auth: { getConnectionToken(name: string): Promise }) {", + ' return auth.getConnectionToken("google");', + "}", + "", + ].join("\n"), + ); + + using _stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + + const result = await runCodemods([{ codemod, scriptPath }], dir, true); + + expect(result.changed).toBe(true); + expect(result.llmReviews).toEqual([ + { + codemodId: "v2/auth-connection-token-helper", + prompt: codemod.prompt, + files: [ + "cjs-require.js", + "collision.ts", + "computed.ts", + "default-import.ts", + "destructure.ts", + "reexported-config.ts", + "shadowed.ts", + ], + findings: [ + expect.objectContaining({ + file: "cjs-require.js", + line: 3, + excerpt: 'exports.token = auth.getConnectionToken("google");', + }), + expect.objectContaining({ + file: "collision.ts", + line: 6, + excerpt: 'return auth.getConnectionToken("google");', + }), + expect.objectContaining({ + file: "computed.ts", + line: 3, + excerpt: 'export const token = await auth["getConnectionToken"]("google");', + }), + expect.objectContaining({ + file: "default-import.ts", + line: 4, + excerpt: 'return config.auth.getConnectionToken("google");', + }), + expect.objectContaining({ + file: "destructure.ts", + line: 3, + excerpt: "export const { getConnectionToken } = auth;", + }), + expect.objectContaining({ + file: "reexported-config.ts", + line: 4, + excerpt: 'return auth.getConnectionToken("google");', + }), + expect.objectContaining({ + file: "shadowed.ts", + line: 4, + excerpt: 'return auth.getConnectionToken("google");', + }), + ], + }, + ]); + }); + + test("suppresses LLM review when a superseding codemod is selected", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-superseded-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "a.ts"), + "createResolver({ authInvoker: auth.invoker(machineUserName) });\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/helper", undefined, ["**/*.ts"], undefined, { + suspiciousPatterns: ["auth.invoker"], + prompt: "Keep authInvoker and unwrap auth.invoker.", + reviewSupersededBy: ["test/rename"], + }), + }, + { + codemod: makeCodemod("test/rename", undefined, ["**/*.ts"], undefined, { + suspiciousPatterns: ["auth.invoker"], + prompt: "Rename authInvoker to invoker and unwrap auth.invoker.", + }), + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/rename", + prompt: "Rename authInvoker to invoker and unwrap auth.invoker.", + files: ["a.ts"], + }, + ]); + }); + + test("AND-group suspicious pattern flags only when every substring co-occurs", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-and-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "unresolved.ts"), + "const serialized = JSON.stringify(payload);\nawait executeScript({ arg: serialized });\n", + ); + await fs.promises.writeFile( + path.join(dir, "already-plain.ts"), + "await executeScript({ arg: payload });\n", + ); + await fs.promises.writeFile( + path.join(dir, "non-arg-json.ts"), + "await executeScript({ code: JSON.stringify(meta) });\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: [["executeScript", "JSON.stringify", "arg:"]], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["unresolved.ts"], + }, + ]); + }); + + test("AND-group suspicious pattern supports regex members", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-regex-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "spaced-colon.ts"), + "const serialized = JSON.stringify(payload);\nawait executeScript({ arg : serialized });\n", + ); + await fs.promises.writeFile( + path.join(dir, "already-plain.ts"), + "await executeScript({ arg : payload });\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: [["executeScript", "JSON.stringify", /\barg\s*:/]], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["spaced-colon.ts"], + }, + ]); + }); + + test("does not flag for LLM review without a prompt", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-noprompt-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "executeScript({ arg: payload });\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([]); + }); + + test("ignores source comments and strings for LLM review patterns", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "a.ts"), + [ + "// executeScript({ arg: payload });", + 'const name = "executeScript";', + "const template = `executeScript`;", + ].join("\n"), + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([]); + }); + + test("flags source strings matching source-string suspicious patterns for LLM review", async () => { + const dir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "runner-llm-source-string-test-"), + ); + tmpDir = dir; + const embeddedCode = [ + 'const client = new tailor.idp.Client({ namespace: "default" });', + "const C = tailor.idp.Client;", + 'await tailor.secretmanager.getSecret("vault", "key");', + "const { getSecret } = tailor.secretmanager;", + "const getInvoker = tailor.context.getInvoker;", + "const { upload } = tailordb.file;", + "const e: TailorErrors = err;", + "type R = Promise>;", + ].join("\\n"); + const typeOnlyEmbeddedCode = [ + "type U = Promise;", + "type Ctor = typeof tailordb.Client;", + "return tailordb.Client;", + "foo(tailordb.Client);", + "type F = () => tailordb.QueryResult;", + ].join("\\n"); + const seedSource = [ + `const code = \`${embeddedCode}\`;`, + 'const note = "tailor.idp.Client is mentioned in prose";', + ].join("\n"); + await fs.promises.writeFile(path.join(dir, "seed.mjs"), seedSource); + await fs.promises.writeFile( + path.join(dir, "escaped.mjs"), + 'const code = "const C =\\n tailor.idp.Client;";', + ); + await fs.promises.writeFile( + path.join(dir, "types.mjs"), + `const code = \`${typeOnlyEmbeddedCode}\`;`, + ); + await fs.promises.writeFile( + path.join(dir, "prose.mjs"), + ['const separator = "=";', 'const note = "tailor.idp.Client is mentioned in prose";'].join( + "\n", + ), + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/llm-source-string", + partialTransformPath, + ["**/*.{ts,js,mjs,cjs}"], + undefined, + { + sourceStringSuspiciousPatterns: [ + "new tailor.idp.Client", + /[=(:,[]\s*tailor\.idp\.Client\b/, + /(?:(?:=>|[=(:,<{]|\[)\s*|\b(?:return|await|typeof)\s+)tailor\.(?:context|idp|secretmanager)(?:\.[A-Za-z_$][\w$]*)?\b/, + /\btailor\.(?:idp|secretmanager)\.[A-Za-z_$][\w$]*\s*\(/, + /(?:(?:[=(:,{]|\[)\s*|\b(?:return|await)\s+)tailordb\.file\b/, + /(?:\bnew\s+|(?:=>|[=(:,<{]|\[)\s*|\b(?:return|await|typeof)\s+)tailordb\.(?:Client|QueryResult)\b/, + /<\s*tailordb\.(?:QueryResult)\b/, + /(?:[:=<]\s*|\bas\s+)Tailor(?:Errors)\b/, + ], + prompt: "Review embedded runtime global usage by hand.", + }, + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toHaveLength(1); + expect(result.llmReviews[0]).toMatchObject({ + codemodId: "test/llm-source-string", + prompt: "Review embedded runtime global usage by hand.", + }); + expect(result.llmReviews[0]?.files).toEqual( + expect.arrayContaining(["escaped.mjs", "seed.mjs", "types.mjs"]), + ); + expect(result.llmReviews[0]?.files).toHaveLength(3); + }); + + test("keeps LLM review patterns inside template substitutions", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "a.ts"), + "const message = `${executeScript({ arg: payload })}`;\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["a.ts"], + }, + ]); + }); + + test("keeps LLM review patterns after nested template substitutions", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "a.ts"), + "const message = `${`prefix ${foo}`.toString(executeScript())}`;\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["a.ts"], + }, + ]); + }); + + test("keeps LLM review patterns after regex literals with escaped slashes", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "a.ts"), + "const re = /https?:\\/\\//; executeScript({ arg: payload });\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["a.ts"], + }, + ]); + }); + + test("emits a blanket LLM review for a codemod-less manual entry", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-manual-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "const x = 1;\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/manual", undefined, ["**/*.ts"], undefined, { + prompt: "Do the manual change.", + }), + scriptPath: undefined, + }, + ], + dir, + true, + ); + + expect(result.changed).toBe(false); + expect(result.llmReviews).toEqual([ + { codemodId: "test/manual", prompt: "Do the manual change.", files: [] }, + ]); + }); + + test("does not emit a blanket review for a legacy-pattern entry with a prompt", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-legacy-prompt-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "const x = 1;\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/legacy", partialTransformPath, ["**/*.ts"], ["needle"], { + prompt: "Finish the residual.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + // legacy-pattern entries surface via warnings, not a blanket llmReview. + expect(result.llmReviews).toEqual([]); + }); + + test("AND-group legacy pattern warns only when every substring co-occurs", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-and-group-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "both.ts"), + "executeScript({ arg: payload });\nconst s = JSON.stringify(x);\n", + "utf-8", + ); + await fs.promises.writeFile( + path.join(dir, "only-stringify.ts"), + "const s = JSON.stringify(x);\n", + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/and-group", + partialTransformPath, + ["**/*.ts"], + [["executeScript", "JSON.stringify"]], + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "both.ts: contains executeScript + JSON.stringify but was not migrated automatically (rule: test/and-group). Manual migration may be needed.", + ]); + }); + + test("flags files matching a suspicious pattern for LLM review", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "executeScript({ arg: payload });\n"); + await fs.promises.writeFile(path.join(dir, "b.ts"), "const x = 1;\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["a.ts"], + }, + ]); + }); + + test("does not flag for LLM review without a prompt", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-noprompt-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "executeScript({ arg: payload });\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([]); + }); }); }); diff --git a/packages/sdk-codemod/src/runner.ts b/packages/sdk-codemod/src/runner.ts index fb55fb2a7..7bc299c74 100644 --- a/packages/sdk-codemod/src/runner.ts +++ b/packages/sdk-codemod/src/runner.ts @@ -1,11 +1,19 @@ import * as fs from "node:fs"; -import { glob } from "node:fs/promises"; import * as url from "node:url"; +import { parse, Lang } from "@ast-grep/napi"; import chalk from "chalk"; import { structuredPatch } from "diff"; import * as path from "pathe"; import picomatch from "picomatch"; -import type { CodemodPackage } from "./types"; +import type { + CodemodPackage, + CodemodPattern, + CodemodPatternGroup, + LlmReview, + LlmReviewFinding, + ReviewFindingsFn, +} from "./types"; +import type { SgNode } from "@ast-grep/napi"; /** * A transform function that receives source text and file path, @@ -41,6 +49,8 @@ export interface CodemodRunResult { warnings: string[]; /** IDs of codemods that actually produced changes in at least one file. */ appliedCodemodIds: Set; + /** Files flagged for LLM-assisted review, grouped by codemod. */ + llmReviews: LlmReview[]; } /** Default file patterns for TypeScript files. */ @@ -48,6 +58,65 @@ const DEFAULT_FILE_PATTERNS = ["**/*.{ts,tsx,mts,cts}"]; /** Directory names always excluded from file scanning. */ const EXCLUDE_DIRS = new Set(["node_modules", "dist", ".git"]); +const ALLOWED_DOT_DIRS = new Set([".github", ".circleci"]); +const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]); +const SOURCE_STRING_FRAGMENT_SEPARATOR = "\0"; +const MASKED_SOURCE_NODE_KINDS: ReadonlySet> = new Set([ + "comment", + "string", + "regex", + "string_fragment", + "jsx_text", +]); +const SOURCE_VALUE_FLAGS = new Set([ + "--env-file-if-exists", + "--env-file", + "--profile", + "--config", + "--workspace-id", + "--arg", + "--query", + "--file", + "--name", + "--namespace", + "--dir", + "-e", + "-p", + "-c", + "-w", + "-a", + "-q", + "-f", + "-n", +]); +const SOURCE_CLI_BINARY_RE = + /^(?:(?:.*[\\/])?tailor(?:\.(?:cmd|ps1|exe))?|(?:.*[\\/])?tailor-sdk(?:@[^\s'"`;|&)]+)?(?:\.(?:cmd|ps1|exe))?|@tailor-platform\/sdk(?:@[^\s'"`;|&)]+)?)$/; + +function shouldSkipDirectory(name: string): boolean { + return EXCLUDE_DIRS.has(name) || (name.startsWith(".") && !ALLOWED_DOT_DIRS.has(name)); +} + +async function* walkFiles(root: string, relativeDir = ""): AsyncGenerator { + const absoluteDir = path.join(root, relativeDir); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(absoluteDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const relative = relativeDir ? path.join(relativeDir, entry.name) : entry.name; + if (entry.isDirectory()) { + if (shouldSkipDirectory(entry.name)) continue; + yield* walkFiles(root, relative); + continue; + } + if (entry.isFile()) { + yield relative; + } + } +} /** * Print a colorized unified diff for a single file to stderr. @@ -82,22 +151,468 @@ function printDiff(filePath: string, before: string, after: string): void { * Load a transform module from a TypeScript file path. * Expects the module to have a default export that is a TransformFn. * @param scriptPath - Absolute path to the transform script - * @returns The transform function + * @returns The transform function and optional review detector */ -async function loadTransform(scriptPath: string): Promise { +async function loadTransformModule( + scriptPath: string, +): Promise<{ transform: TransformFn; reviewFindings?: ReviewFindingsFn }> { const mod = await import(url.pathToFileURL(scriptPath).href); if (typeof mod.default !== "function") { throw new Error(`Transform at ${scriptPath} does not have a default export function`); } - return mod.default as TransformFn; + return { + transform: mod.default as TransformFn, + reviewFindings: + typeof mod.reviewFindings === "function" + ? (mod.reviewFindings as ReviewFindingsFn) + : undefined, + }; } /** A loaded transform with its file matcher. */ interface LoadedTransform { id: string; - transform: TransformFn; + /** Undefined for codemod-less ("manual") entries that ship only guidance. */ + transform?: TransformFn; + reviewFindings?: ReviewFindingsFn; matches: (relativePath: string) => boolean; - legacyPatterns: string[]; + legacyPatterns: CodemodPatternGroup[]; + sourceStringLegacyPatterns: CodemodPatternGroup[]; + sourceTextLegacyPatterns: CodemodPatternGroup[]; + suspiciousPatterns: CodemodPatternGroup[]; + sourceStringSuspiciousPatterns: CodemodPatternGroup[]; + prompt?: string; + reviewSupersededBy: string[]; +} + +function contentForResidualMatching(relative: string, content: string): string { + const ext = path.extname(relative).toLowerCase(); + return SOURCE_EXTENSIONS.has(ext) ? maskSourceNonCode(relative, content) : content; +} + +function sourceStringFragmentGapForResidualMatching(gap: string): string { + if (/^\\["']$/.test(gap)) return gap.slice(1); + return /^(?:\\(?:[nrtvf]|\r\n|\r|\n)|\s)+$/.test(gap) ? " " : SOURCE_STRING_FRAGMENT_SEPARATOR; +} + +function sourceStringNodeContentForResidualMatching(node: SgNode, content: string): string | null { + const parts: string[] = []; + let previousFragmentEnd: number | null = null; + + for (const child of node.children()) { + if (child.kind() !== "string_fragment") continue; + + const range = child.range(); + if (previousFragmentEnd != null && range.start.index > previousFragmentEnd) { + parts.push( + sourceStringFragmentGapForResidualMatching( + content.slice(previousFragmentEnd, range.start.index), + ), + ); + } + parts.push(child.text()); + previousFragmentEnd = range.end.index; + } + + return parts.length === 0 ? null : parts.join(""); +} + +function sourceStringContentForResidualMatching(relative: string, content: string): string | null { + const ext = path.extname(relative).toLowerCase(); + if (!SOURCE_EXTENSIONS.has(ext)) return null; + + let root: SgNode; + try { + root = parse(sourceLang(relative), content).root(); + } catch { + return null; + } + + const sourceStrings: string[] = []; + const visit = (node: SgNode): void => { + if (node.kind() === "arguments") { + const value = sourceArgumentsCommandContent(node, content); + if (value != null) sourceStrings.push(value); + } + if (node.kind() === "array") { + const value = sourceArrayCommandContent(node, content); + if (value != null) sourceStrings.push(value); + } + const kind = node.kind(); + if (kind === "string" || kind === "template_string") { + if (isSourceTailorSdkValueArgument(node, content)) return; + const sourceString = sourceStringNodeContentForResidualMatching(node, content); + if (sourceString != null) sourceStrings.push(sourceString); + } + for (const child of node.children()) { + if (child.kind() === "string_fragment") continue; + visit(child); + } + }; + visit(root); + return sourceStrings.join(SOURCE_STRING_FRAGMENT_SEPARATOR); +} + +function isConstVariableDeclarator(node: SgNode): boolean { + return ( + node + .parent() + ?.children() + .some((child) => child.kind() === "const") ?? false + ); +} + +function sourceTextContentForResidualMatching(relative: string, content: string): string | null { + const ext = path.extname(relative).toLowerCase(); + if (!SOURCE_EXTENSIONS.has(ext)) return null; + + let root: SgNode; + try { + root = parse(sourceLang(relative), content).root(); + } catch { + return null; + } + + const fragments: string[] = []; + const visit = (node: SgNode): void => { + if (node.kind() === "comment" || node.kind() === "jsx_text") { + fragments.push(node.text()); + return; + } + for (const child of node.children()) { + visit(child); + } + }; + visit(root); + return fragments.join(SOURCE_STRING_FRAGMENT_SEPARATOR); +} + +function sourceArgumentsCommandContent(node: SgNode, source: string): string | null { + const args = sourceArrayElements(node); + const executable = args[0] == null ? null : sourceStringLikeNodeContent(args[0]!, source); + const argv = args[1]; + if (executable == null || argv?.kind() !== "array") return null; + + const values = sourceArrayCommandValues(argv, source); + return values.length === 0 ? null : [executable, ...values].join(" "); +} + +function sourceArrayCommandContent(node: SgNode, source: string): string | null { + const values = sourceArrayCommandValues(node, source); + return values.length < 2 ? null : values.join(" "); +} + +function sourceArrayCommandValues(node: SgNode, source: string): string[] { + const values: string[] = []; + for (const element of sourceArrayElements(node)) { + if (isSourceValueArgument(element, source)) continue; + const value = sourceStringLikeNodeContent(element, source); + if (value != null) values.push(value); + } + return values; +} + +function isSourceTailorSdkValueArgument(fragment: SgNode, source: string): boolean { + const text = + fragment.kind() === "string_fragment" + ? fragment.text() + : sourceStringLikeNodeContent(fragment, source); + return text != null && text.includes("tailor-sdk") && isSourceValueArgument(fragment, source); +} + +function isSyntaxOnlyNode(node: SgNode): boolean { + const kind = node.kind(); + return ( + kind === "[" || + kind === "]" || + kind === "(" || + kind === ")" || + kind === "," || + kind === "comment" + ); +} + +function sourceArrayElements(node: SgNode): SgNode[] { + return node.children().filter((child: SgNode) => !isSyntaxOnlyNode(child)); +} + +function nodeRangeKey(node: SgNode): string { + const range = node.range(); + return `${range.start.index}:${range.end.index}`; +} + +function sourceStringLikeNodeContent(node: SgNode, source: string): string | null { + const directValue = sourceStringNodeContent(node, source); + if (directValue != null) return directValue; + return node.kind() === "identifier" ? sourceScopedStringVariableContent(node, source) : null; +} + +function sourceScopedStringVariableContent(identifier: SgNode, source: string): string | null { + const name = identifier.text(); + const before = identifier.range().start.index; + let current = identifier.parent(); + while (current != null) { + if (isSourceScopeNode(current)) { + const value = findSourceStringVariableInScope(current, name, before, source); + if (value != null) return value; + } + current = current.parent(); + } + return null; +} + +function findSourceStringVariableInScope( + scope: SgNode, + name: string, + before: number, + source: string, +): string | null { + let value: string | null = null; + const visit = (node: SgNode): void => { + if (node !== scope && isSourceScopeNode(node)) return; + if (node.kind() === "variable_declarator" && node.range().end.index < before) { + const declarationValue = sourceStringVariableDeclarationValue(node, name, source); + if (declarationValue != null) value = declarationValue; + return; + } + for (const child of node.children()) { + visit(child); + } + }; + visit(scope); + return value; +} + +function sourceStringVariableDeclarationValue( + node: SgNode, + name: string, + source: string, +): string | null { + if (!isConstVariableDeclarator(node)) return null; + const children = node.children(); + const identifier = children.find((child) => child.kind() === "identifier"); + if (identifier?.text() !== name) return null; + const initializer = children.findLast( + (child) => sourceConstInitializerContent(child, source) != null, + ); + return initializer == null ? null : sourceConstInitializerContent(initializer, source); +} + +function sourceConstInitializerContent(node: SgNode, source: string): string | null { + const directValue = sourceStringNodeContent(node, source); + if (directValue != null) return directValue; + if ( + node.kind() !== "as_expression" && + node.kind() !== "satisfies_expression" && + node.kind() !== "parenthesized_expression" + ) { + return null; + } + for (const child of node.children()) { + const childValue = sourceConstInitializerContent(child, source); + if (childValue != null) return childValue; + } + return null; +} + +function isSourceScopeNode(node: SgNode): boolean { + const kind = node.kind(); + return ( + kind === "program" || + kind === "statement_block" || + kind === "function_declaration" || + kind === "arrow_function" || + kind === "method_definition" + ); +} + +function sourceStringNodeContent(node: SgNode, source: string): string | null { + const kind = node.kind(); + if (kind !== "string" && kind !== "template_string") return null; + if ( + kind === "template_string" && + node.children().some((child: SgNode) => child.kind() === "template_substitution") + ) { + return null; + } + const range = node.range(); + return source.slice(range.start.index + 1, range.end.index - 1); +} + +function isSourceValueArgument(fragment: SgNode, source: string): boolean { + const stringNode = fragment.kind() === "string_fragment" ? fragment.parent() : fragment; + if (stringNode == null) return false; + const parent = stringNode.parent(); + if (parent?.kind() !== "array") return false; + + const elements = sourceArrayElements(parent); + const index = elements.findIndex((element) => nodeRangeKey(element) === nodeRangeKey(stringNode)); + if (index <= 0) return false; + if (!isTailorCliArgumentArray(parent, index, source)) return false; + + const previous = sourceStringLikeNodeContent(elements[index - 1]!, source); + return ( + previous != null && + SOURCE_VALUE_FLAGS.has(previous.split("=", 1)[0]!) && + !previous.includes("=") + ); +} + +function isTailorCliArgumentArray(arrayNode: SgNode, index: number, source: string): boolean { + const argumentsNode = arrayNode.parent(); + if (argumentsNode?.kind() === "arguments") { + const callArgs = sourceArrayElements(argumentsNode); + const executable = + callArgs[0] == null ? null : sourceStringLikeNodeContent(callArgs[0]!, source); + if (executable != null && SOURCE_CLI_BINARY_RE.test(executable)) return true; + } + + const elements = sourceArrayElements(arrayNode); + return elements.slice(0, index).some((element) => { + const value = sourceStringLikeNodeContent(element, source); + return value != null && SOURCE_CLI_BINARY_RE.test(value); + }); +} + +function sourceLang(relative: string): Lang { + const ext = path.extname(relative).toLowerCase(); + return ext === ".tsx" || ext === ".jsx" || ext === ".js" ? Lang.Tsx : Lang.TypeScript; +} + +function isProcessEnvSubscriptKey(node: SgNode): boolean { + const stringNode = node.kind() === "string_fragment" ? node.parent() : node; + if (stringNode == null) return false; + const stringNodeKind = stringNode.kind(); + if (stringNodeKind !== "string" && stringNodeKind !== "template_string") { + return false; + } + const parent = stringNode.parent(); + return parent?.kind() === "subscript_expression" && /^process\.env\s*\[/.test(parent.text()); +} + +function collectMaskedRanges(root: SgNode): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + const visit = (node: SgNode): void => { + if (MASKED_SOURCE_NODE_KINDS.has(node.kind())) { + if (isProcessEnvSubscriptKey(node)) return; + const range = node.range(); + ranges.push([range.start.index, range.end.index]); + return; + } + for (const child of node.children()) { + visit(child); + } + }; + visit(root); + return ranges; +} + +function maskSourceNonCode(relative: string, content: string): string { + let ranges: Array<[number, number]>; + try { + const root = parse(sourceLang(relative), content).root(); + ranges = collectMaskedRanges(root); + } catch { + return content; + } + + ranges = ranges.toSorted(([a], [b]) => a - b); + const chars = content.split(""); + for (const [start, end] of ranges) { + for (let i = start; i < end && i < chars.length; i++) { + if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " "; + } + } + return chars.join(""); +} + +function isIdentifierChar(char: string | undefined): boolean { + return char != null && /^[A-Za-z0-9_$]$/.test(char); +} + +function matchesPattern(content: string, pattern: CodemodPattern): boolean { + if (typeof pattern === "string") { + const checkLeft = isIdentifierChar(pattern[0]); + const checkRight = isIdentifierChar(pattern.at(-1)); + let index = content.indexOf(pattern); + while (index !== -1) { + const before = index > 0 ? content[index - 1] : undefined; + const after = content[index + pattern.length]; + if ((!checkLeft || !isIdentifierChar(before)) && (!checkRight || !isIdentifierChar(after))) { + return true; + } + index = content.indexOf(pattern, index + 1); + } + return false; + } + pattern.lastIndex = 0; + return pattern.test(content); +} + +function patternLabel(pattern: CodemodPattern): string { + return typeof pattern === "string" ? pattern : pattern.toString(); +} + +/** Resolve a residual pattern against content, returning its label when matched. */ +function matchResidualPattern(content: string, pattern: CodemodPatternGroup): string | null { + if (!Array.isArray(pattern)) { + return matchesPattern(content, pattern) ? patternLabel(pattern) : null; + } + return pattern.every((p) => matchesPattern(content, p)) + ? pattern.map((p) => patternLabel(p)).join(" + ") + : null; +} + +function matchResidualPatternFragment( + content: string, + pattern: CodemodPatternGroup, +): string | null { + for (const fragment of content.split(SOURCE_STRING_FRAGMENT_SEPARATOR)) { + const label = matchResidualPattern(fragment, pattern); + if (label != null) return label; + } + return null; +} + +function legacyPatternWarnings( + relative: string, + content: string, + sourceStringContent: string | null, + sourceTextContent: string | null, + transforms: LoadedTransform[], +): string[] { + return transforms.flatMap((lt) => { + const found = new Set( + lt.legacyPatterns + .map((p) => matchResidualPattern(content, p)) + .filter((label): label is string => label !== null), + ); + if (sourceStringContent != null) { + for (const pattern of lt.sourceStringLegacyPatterns) { + const label = matchResidualPatternFragment(sourceStringContent, pattern); + if (label != null) found.add(label); + } + } + if (sourceTextContent != null) { + for (const pattern of lt.sourceTextLegacyPatterns) { + const label = matchResidualPatternFragment(sourceTextContent, pattern); + if (label != null) found.add(label); + } + } + if (found.size === 0) return []; + return [ + `${relative}: contains ${Array.from(found).join(", ")} but was not migrated automatically (rule: ${lt.id}). Manual migration may be needed.`, + ]; + }); +} + +function compareReviewFindings(a: LlmReviewFinding, b: LlmReviewFinding): number { + return ( + a.file.localeCompare(b.file) || + a.line - b.line || + a.message.localeCompare(b.message) || + a.excerpt.localeCompare(b.excerpt) + ); } /** @@ -112,7 +627,7 @@ interface LoadedTransform { * @returns Combined result of all codemod executions */ export async function runCodemods( - codemods: Array<{ codemod: CodemodPackage; scriptPath: string }>, + codemods: Array<{ codemod: CodemodPackage; scriptPath?: string }>, targetPath: string, dryRun: boolean, ): Promise { @@ -120,75 +635,139 @@ export async function runCodemods( const loaded: LoadedTransform[] = []; for (const { codemod, scriptPath } of codemods) { const patterns = codemod.filePatterns ?? DEFAULT_FILE_PATTERNS; + const loadedModule = scriptPath ? await loadTransformModule(scriptPath) : undefined; loaded.push({ id: codemod.id, - transform: await loadTransform(scriptPath), - matches: picomatch(patterns), + transform: loadedModule?.transform, + reviewFindings: loadedModule?.reviewFindings, + matches: picomatch(patterns, { dot: true }), legacyPatterns: codemod.legacyPatterns ?? [], + sourceStringLegacyPatterns: codemod.sourceStringLegacyPatterns ?? [], + sourceTextLegacyPatterns: codemod.sourceTextLegacyPatterns ?? [], + suspiciousPatterns: codemod.suspiciousPatterns ?? [], + sourceStringSuspiciousPatterns: codemod.sourceStringSuspiciousPatterns ?? [], + prompt: codemod.prompt, + reviewSupersededBy: codemod.reviewSupersededBy ?? [], }); } - // Collect all unique file patterns for glob scanning - const allPatterns = new Set(); - for (const { codemod } of codemods) { - for (const p of codemod.filePatterns ?? DEFAULT_FILE_PATTERNS) { - allPatterns.add(p); - } - } - const filesModified: string[] = []; const warnings: string[] = []; const appliedCodemodIds = new Set(); const seen = new Set(); + const suspiciousByCodemod = new Map>(); + const findingsByCodemod = new Map(); + + for await (const relative of walkFiles(targetPath)) { + const absolute = path.resolve(targetPath, relative); + if (seen.has(absolute)) continue; + seen.add(absolute); + + const matchedTransforms = loaded.filter((lt) => lt.matches(relative)); + if (matchedTransforms.length === 0) continue; + + let original: string; + try { + original = await fs.promises.readFile(absolute, "utf-8"); + } catch { + continue; + } - // Iterate over all matching files (deduplicate across patterns) - for (const pattern of allPatterns) { - for await (const relative of glob(pattern, { - cwd: targetPath, - exclude: (name) => EXCLUDE_DIRS.has(name), - })) { - const absolute = path.resolve(targetPath, relative); - if (seen.has(absolute)) continue; - seen.add(absolute); - - let original: string; - try { - original = await fs.promises.readFile(absolute, "utf-8"); - } catch { - continue; + let current = original; + for (const lt of matchedTransforms) { + if (!lt.transform) continue; + const result = await lt.transform(current, absolute); + if (result != null) { + current = result; + appliedCodemodIds.add(lt.id); } + } - // Chain only transforms whose filePatterns match this file - let current = original; - const matchedTransforms: LoadedTransform[] = []; - for (const lt of loaded) { - if (!lt.matches(relative)) continue; - matchedTransforms.push(lt); - const result = await lt.transform(current, absolute); - if (result != null) { - current = result; - appliedCodemodIds.add(lt.id); - } + if (current !== original) { + filesModified.push(absolute); + if (dryRun) { + printDiff(absolute, original, current); + } else { + await fs.promises.writeFile(absolute, current, "utf-8"); } + } - if (current !== original) { - filesModified.push(absolute); - if (dryRun) { - printDiff(absolute, original, current); - } else { - await fs.promises.writeFile(absolute, current, "utf-8"); + const residualContent = contentForResidualMatching(relative, current); + const sourceStringContent = sourceStringContentForResidualMatching(relative, current); + const sourceTextContent = sourceTextContentForResidualMatching(relative, current); + warnings.push( + ...legacyPatternWarnings( + relative, + residualContent, + sourceStringContent, + sourceTextContent, + matchedTransforms, + ), + ); + + for (const lt of matchedTransforms) { + if (!lt.prompt) continue; + const filesForReview = (): Set => { + let files = suspiciousByCodemod.get(lt.id); + if (!files) { + files = new Set(); + suspiciousByCodemod.set(lt.id, files); } - } else { - // Check each matched codemod's legacyPatterns for unmodified files - for (const lt of matchedTransforms) { - const found = lt.legacyPatterns.filter((p) => original.includes(p)); - if (found.length > 0) { - warnings.push( - `${relative}: contains ${found.join(", ")} but was not migrated automatically (rule: ${lt.id}). Manual migration may be needed.`, - ); + return files; + }; + if (lt.reviewFindings) { + const findings = await lt.reviewFindings(current, absolute, relative); + if (findings.length > 0) { + const files = filesForReview(); + for (const finding of findings) { + files.add(finding.file); } + let existing = findingsByCodemod.get(lt.id); + if (!existing) { + existing = []; + findingsByCodemod.set(lt.id, existing); + } + existing.push(...findings); } } + const matchesSource = + lt.suspiciousPatterns.some((p) => matchResidualPattern(residualContent, p) !== null) || + (sourceStringContent != null && + lt.sourceStringSuspiciousPatterns.some( + (p) => matchResidualPattern(sourceStringContent, p) !== null, + )); + if (matchesSource) { + filesForReview().add(relative); + } + } + } + + const llmReviews: LlmReview[] = []; + const loadedIds = new Set(loaded.map((lt) => lt.id)); + for (const lt of loaded) { + if (!lt.prompt) continue; + if (lt.reviewSupersededBy.some((id) => loadedIds.has(id))) continue; + if ( + lt.suspiciousPatterns.length > 0 || + lt.sourceStringSuspiciousPatterns.length > 0 || + lt.reviewFindings + ) { + // File-scoped: only surface when a suspicious pattern actually matched. + const files = suspiciousByCodemod.get(lt.id); + // Sort for deterministic output regardless of filesystem traversal order. + if (files) { + const findings = findingsByCodemod.get(lt.id)?.toSorted(compareReviewFindings); + llmReviews.push({ + codemodId: lt.id, + prompt: lt.prompt, + files: Array.from(files).toSorted(), + ...(findings && findings.length > 0 ? { findings } : {}), + }); + } + } else if (lt.legacyPatterns.length === 0) { + // Codemod-less manual change with no pattern to scope by: surface as + // project-wide guidance (legacyPattern-only entries warn instead). + llmReviews.push({ codemodId: lt.id, prompt: lt.prompt, files: [] }); } } @@ -197,5 +776,6 @@ export async function runCodemods( filesModified, warnings, appliedCodemodIds, + llmReviews, }; } diff --git a/packages/sdk-codemod/src/transform.test.ts b/packages/sdk-codemod/src/transform.test.ts index eda87d836..5daf91c10 100644 --- a/packages/sdk-codemod/src/transform.test.ts +++ b/packages/sdk-codemod/src/transform.test.ts @@ -79,6 +79,10 @@ describe("codemod transforms", () => { await expect(runFixtureCases("v2/principal-unify")).resolves.toBeUndefined(); }); + test("v2/auth-attributes-rename transforms correctly", async () => { + await expect(runFixtureCases("v2/auth-attributes-rename")).resolves.toBeUndefined(); + }); + test("v2/apply-to-deploy transforms correctly", async () => { await expect(runFixtureCases("v2/apply-to-deploy")).resolves.toBeUndefined(); }); @@ -87,11 +91,55 @@ describe("codemod transforms", () => { await expect(runFixtureCases("v2/cli-rename")).resolves.toBeUndefined(); }); + test("v2/env-var-rename transforms correctly", async () => { + await expect(runFixtureCases("v2/env-var-rename")).resolves.toBeUndefined(); + }); + + test("v2/auth-invoker-call-unwrap transforms correctly", async () => { + await expect(runFixtureCases("v2/auth-invoker-call-unwrap")).resolves.toBeUndefined(); + }); + test("v2/auth-invoker-unwrap transforms correctly", async () => { await expect(runFixtureCases("v2/auth-invoker-unwrap")).resolves.toBeUndefined(); }); + test("v2/auth-connection-token-helper transforms correctly", async () => { + await expect(runFixtureCases("v2/auth-connection-token-helper")).resolves.toBeUndefined(); + }); + + test("v2/runtime-subpath-namespace transforms correctly", async () => { + await expect(runFixtureCases("v2/runtime-subpath-namespace")).resolves.toBeUndefined(); + }); + test("v2/tailordb-namespace transforms correctly", async () => { await expect(runFixtureCases("v2/tailordb-namespace")).resolves.toBeUndefined(); }); + + test("v2/runtime-globals-opt-in transforms correctly", async () => { + await expect(runFixtureCases("v2/runtime-globals-opt-in")).resolves.toBeUndefined(); + }); + + test("v2/execute-script-arg transforms correctly", async () => { + await expect(runFixtureCases("v2/execute-script-arg")).resolves.toBeUndefined(); + }); + + test("v2/tailor-output-ignore-dir transforms correctly", async () => { + await expect(runFixtureCases("v2/tailor-output-ignore-dir")).resolves.toBeUndefined(); + }); + + test("v2/rename-bin transforms correctly", async () => { + await expect(runFixtureCases("v2/rename-bin")).resolves.toBeUndefined(); + }); + + test("v2/wait-point-rename transforms correctly", async () => { + await expect(runFixtureCases("v2/wait-point-rename")).resolves.toBeUndefined(); + }); + + test("v2/db-type-to-table transforms correctly", async () => { + await expect(runFixtureCases("v2/db-type-to-table")).resolves.toBeUndefined(); + }); + + test("v2/workflow-trigger-rename transforms correctly", async () => { + await expect(runFixtureCases("v2/workflow-trigger-rename")).resolves.toBeUndefined(); + }); }); diff --git a/packages/sdk-codemod/src/types.ts b/packages/sdk-codemod/src/types.ts index f4bcd3245..5795cd651 100644 --- a/packages/sdk-codemod/src/types.ts +++ b/packages/sdk-codemod/src/types.ts @@ -1,3 +1,21 @@ +import type { RunnerMetadata } from "./runner-metadata"; + +/** A before/after code pair shown in the generated migration doc. */ +export interface CodemodExample { + /** Code as written before the migration. */ + before: string; + /** Code after the migration. */ + after: string; + /** Optional one-line caption explaining the example. */ + caption?: string; + /** Fenced-code-block language for the example (default: "ts"). */ + lang?: string; +} + +export type CodemodPattern = string | RegExp; + +export type CodemodPatternGroup = CodemodPattern | CodemodPattern[]; + /** * Metadata for a codemod package. */ @@ -12,23 +30,119 @@ export interface CodemodPackage { since: string; /** Target version this codemod upgrades to (semver, exclusive upper bound) */ until: string; - /** Path to the jssg transform script relative to the codemods root */ - scriptPath: string; + /** Earliest prerelease target that should apply this codemod before `until` is stable. */ + prereleaseUntil?: string; + /** + * Path to the jssg transform script relative to the codemods root. Omit for a + * codemod-less ("manual") migration that ships only guidance — `prompt`, + * `examples`, and/or `suspiciousPatterns` — with no automatic transform. + */ + scriptPath?: string; /** Target language for codemod CLI (default: "typescript") */ language?: string; /** Custom file glob patterns. Defaults to TypeScript patterns when omitted. */ filePatterns?: string[]; - /** Legacy patterns to detect in unmodified files for manual migration warnings. */ - legacyPatterns?: string[]; + /** + * Patterns to detect in post-transform file content for manual migration + * warnings. A plain string warns when that substring is present, a `RegExp` + * warns when it matches, and an array group warns only when every member is + * present (AND), letting a rule target a co-occurrence such as + * `executeScript` used together with `JSON.stringify`. In source files, + * comments and string literals are masked before matching, and identifier-like + * string patterns must match token boundaries. + */ + legacyPatterns?: CodemodPatternGroup[]; + /** + * Patterns to detect only inside string/template fragments of source files + * after a transform runs. Use this when a migration normally masks source + * strings for residual matching, but selected string content still needs a + * manual follow-up warning. + */ + sourceStringLegacyPatterns?: CodemodPatternGroup[]; + /** + * Patterns to detect only inside comments and JSX text of source files after + * a transform runs. Use this for source text that is intentionally masked + * from generic residual matching, but still contains user-facing command + * examples that need a manual follow-up warning. + */ + sourceTextLegacyPatterns?: CodemodPatternGroup[]; + /** + * Patterns that, when present in a file's post-transform content, mark it + * as a candidate for LLM-assisted review. Use this for migrations the + * deterministic transform cannot safely complete on its own (e.g. a value + * reached through a variable or a dynamic expression). An array group + * matches only when every pattern in the group is present (AND). Unlike + * `legacyPatterns`, these do not need to be exhaustive: a broad signal such + * as the API name is enough to point an LLM at the right files. Source files + * use the same comment/string and token-boundary matching as + * `legacyPatterns`. Has no effect unless `prompt` is also set. + */ + suspiciousPatterns?: CodemodPatternGroup[]; + /** + * Patterns to detect only inside string/template fragments of source files + * for LLM-assisted review. Use this when source strings normally remain + * masked for `suspiciousPatterns`, but embedded code snippets may still need + * manual migration. Has no effect unless `prompt` is also set. + */ + sourceStringSuspiciousPatterns?: CodemodPatternGroup[]; + /** + * Prompt that instructs an LLM how to finish the migration for files matched + * by `suspiciousPatterns` or `sourceStringSuspiciousPatterns`. + */ + prompt?: string; + /** Codemod ids whose LLM review prompt supersedes this prompt when both are selected. */ + reviewSupersededBy?: string[]; + /** Before/after examples shown in the generated migration doc. */ + examples?: CodemodExample[]; + /** + * Marks an informational behavioral change (a runtime/CLI change with no + * source to migrate), not a migration. Rendered in a separate "Behavioral + * changes" section, never on the automation-level axis. + */ + notice?: boolean; +} + +/** A specific location that needs manual or LLM-assisted migration review. */ +export interface LlmReviewFinding { + /** File path relative to the transformed project root. */ + file: string; + /** One-based line number in the post-transform file content. */ + line: number; + /** Short reason this location needs review. */ + message: string; + /** Trimmed source line or nearby expression for local context. */ + excerpt: string; +} + +/** Detector exported by a transform module for precise review locations. */ +export type ReviewFindingsFn = ( + source: string, + filePath: string, + relativePath: string, +) => Promise | LlmReviewFinding[]; + +/** A batch of files an LLM should review for one codemod, with its prompt. */ +export interface LlmReview { + /** Codemod id that flagged these files. */ + codemodId: string; + /** Prompt describing the migration for an LLM. */ + prompt: string; + /** Files (relative to the target) that matched a suspicious pattern. */ + files: string[]; + /** Optional file-local findings produced by the codemod script. */ + findings?: LlmReviewFinding[]; } /** * JSON output written to stdout by the sdk-codemod CLI. */ export interface RunOutput { + runner: RunnerMetadata; codemodsApplied: number; codemodsSkipped: number; filesModified: string[]; warnings: string[]; errors: Array<{ codemodId: string; message: string }>; + /** Files flagged for LLM-assisted review, grouped by codemod. */ + llmReviews: LlmReview[]; } diff --git a/packages/sdk-codemod/tsdown.config.ts b/packages/sdk-codemod/tsdown.config.ts index 1eb5e6c31..8b3c296ae 100644 --- a/packages/sdk-codemod/tsdown.config.ts +++ b/packages/sdk-codemod/tsdown.config.ts @@ -23,12 +23,35 @@ export default defineConfig([ "codemods/v2/test-run-arg-input/scripts/transform.ts", "v2/sdk-skills-shim/scripts/transform": "codemods/v2/sdk-skills-shim/scripts/transform.ts", "v2/principal-unify/scripts/transform": "codemods/v2/principal-unify/scripts/transform.ts", + "v2/auth-attributes-rename/scripts/transform": + "codemods/v2/auth-attributes-rename/scripts/transform.ts", "v2/apply-to-deploy/scripts/transform": "codemods/v2/apply-to-deploy/scripts/transform.ts", "v2/cli-rename/scripts/transform": "codemods/v2/cli-rename/scripts/transform.ts", + "v2/env-var-rename/scripts/transform": "codemods/v2/env-var-rename/scripts/transform.ts", + "v2/auth-invoker-call-unwrap/scripts/transform": + "codemods/v2/auth-invoker-call-unwrap/scripts/transform.ts", "v2/auth-invoker-unwrap/scripts/transform": "codemods/v2/auth-invoker-unwrap/scripts/transform.ts", + "v2/auth-connection-token-helper/scripts/transform": + "codemods/v2/auth-connection-token-helper/scripts/transform.ts", + "v2/runtime-subpath-namespace/scripts/transform": + "codemods/v2/runtime-subpath-namespace/scripts/transform.ts", "v2/tailordb-namespace/scripts/transform": "codemods/v2/tailordb-namespace/scripts/transform.ts", + "v2/db-type-to-table/scripts/transform": "codemods/v2/db-type-to-table/scripts/transform.ts", + "v2/forward-relation-name/scripts/transform": + "codemods/v2/forward-relation-name/scripts/transform.ts", + "v2/runtime-globals-opt-in/scripts/transform": + "codemods/v2/runtime-globals-opt-in/scripts/transform.ts", + "v2/execute-script-arg/scripts/transform": + "codemods/v2/execute-script-arg/scripts/transform.ts", + "v2/tailor-output-ignore-dir/scripts/transform": + "codemods/v2/tailor-output-ignore-dir/scripts/transform.ts", + "v2/rename-bin/scripts/transform": "codemods/v2/rename-bin/scripts/transform.ts", + "v2/wait-point-rename/scripts/transform": + "codemods/v2/wait-point-rename/scripts/transform.ts", + "v2/workflow-trigger-rename/scripts/transform": + "codemods/v2/workflow-trigger-rename/scripts/transform.ts", }, format: ["esm"], target: "node18", diff --git a/packages/sdk-plugin-tailordb-erd/.oxlintrc.json b/packages/sdk-plugin-tailordb-erd/.oxlintrc.json new file mode 100644 index 000000000..fa4b78afe --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/.oxlintrc.json @@ -0,0 +1,64 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn"], + "categories": { + "correctness": "error" + }, + "env": { + "builtin": true + }, + "ignorePatterns": ["dist/", "src/viewer-assets/"], + "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-tailordb-erd/CHANGELOG.md b/packages/sdk-plugin-tailordb-erd/CHANGELOG.md new file mode 100644 index 000000000..aca2c9a97 --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/CHANGELOG.md @@ -0,0 +1,23 @@ +# @tailor-platform/sdk-tailordb-erd-plugin + +## 0.1.0-next.1 + +### Minor Changes + +- [#1801](https://github.com/tailor-platform/sdk/pull/1801) [`ee382c7`](https://github.com/tailor-platform/sdk/commit/ee382c7d5f5c0a14acf47c1dee6f12d8cecad92d) Thanks [@toiroakr](https://github.com/toiroakr)! - Renamed the package from `@tailor-platform/sdk-tailordb-erd-plugin` to `@tailor-platform/sdk-plugin-tailordb-erd`, following the `eslint-plugin-*`-style naming convention used for CLI plugin packages. Update your dependency to the new name; the `tailor-tailordb-erd` executable and the `tailor tailordb erd` commands are unchanged. + +### Patch Changes + +- Updated dependencies [[`f1cbda5`](https://github.com/tailor-platform/sdk/commit/f1cbda56df96670f18dccf2b7f2473430584f377), [`c870196`](https://github.com/tailor-platform/sdk/commit/c8701961f90d7bdcc887c793c806d4f26cc9b197), [`d07a82a`](https://github.com/tailor-platform/sdk/commit/d07a82aa4ded74c3d84e157b4bed5c37ef0ec239), [`da7d0c4`](https://github.com/tailor-platform/sdk/commit/da7d0c49322deebc9343dee88652152620a7cef9), [`cb97bd4`](https://github.com/tailor-platform/sdk/commit/cb97bd45314c5897818233dc8bc3b84b83bea8a3), [`ee382c7`](https://github.com/tailor-platform/sdk/commit/ee382c7d5f5c0a14acf47c1dee6f12d8cecad92d), [`c971797`](https://github.com/tailor-platform/sdk/commit/c971797c9bfa035a43771c46f2b1c3bd93f989a9), [`c971797`](https://github.com/tailor-platform/sdk/commit/c971797c9bfa035a43771c46f2b1c3bd93f989a9)]: + - @tailor-platform/sdk@2.0.0-next.7 + +## 0.1.0-next.0 + +### Minor Changes + +- [#1776](https://github.com/tailor-platform/sdk/pull/1776) [`7338457`](https://github.com/tailor-platform/sdk/commit/733845732fabff504c32b725f6919be6167bc7a1) Thanks [@dqn](https://github.com/dqn)! - New Tailor CLI plugin providing the `tailor tailordb erd` commands (export, diff, serve, deploy), extracted from `@tailor-platform/sdk`. + +### Patch Changes + +- Updated dependencies [[`7338457`](https://github.com/tailor-platform/sdk/commit/733845732fabff504c32b725f6919be6167bc7a1), [`7338457`](https://github.com/tailor-platform/sdk/commit/733845732fabff504c32b725f6919be6167bc7a1), [`9837182`](https://github.com/tailor-platform/sdk/commit/983718288eab23a4c15762c179a67569dd78a287), [`1316447`](https://github.com/tailor-platform/sdk/commit/13164471cc934da03dd64640b2fb2457c79b4413), [`000db7e`](https://github.com/tailor-platform/sdk/commit/000db7ef91b699918a8da600faa183ebcb40ba7c), [`36ad006`](https://github.com/tailor-platform/sdk/commit/36ad006f9eb2d23b21b2028741dbce1d6ba9f91b), [`322b69c`](https://github.com/tailor-platform/sdk/commit/322b69c843953551ccb4b32d7cbd528ae2b0e10c), [`813f86e`](https://github.com/tailor-platform/sdk/commit/813f86eb0b0df1a768db5de6a39a550d6633749a), [`09f5691`](https://github.com/tailor-platform/sdk/commit/09f5691ef5a76761812f039d125d33eb3211994a)]: + - @tailor-platform/sdk@2.0.0-next.6 diff --git a/packages/sdk-plugin-tailordb-erd/README.md b/packages/sdk-plugin-tailordb-erd/README.md new file mode 100644 index 000000000..a18a728c7 --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/README.md @@ -0,0 +1,45 @@ +# @tailor-platform/sdk-plugin-tailordb-erd + +Tailor CLI plugin that provides the `tailor tailordb erd` commands: export, diff, serve, and deploy a TailorDB ERD viewer built from your local TailorDB schema. + +> [!NOTE] +> This package is a **CLI plugin**: it ships an external `tailor-tailordb-erd` executable that the Tailor CLI dispatches to when you run `tailor tailordb erd`. It is not a config plugin — do not pass it to `definePlugins()` in `tailor.config.ts`. + +## Installation + +Install it next to `@tailor-platform/sdk` in your project: + +```bash +npm install -D @tailor-platform/sdk-plugin-tailordb-erd@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 +# Build the ERD viewer for a namespace into .tailor/erd//dist +tailor tailordb erd export --namespace my-db + +# Serve the ERD viewer locally with watch reload +tailor tailordb erd serve --namespace my-db --open + +# Render an HTML diff between two exported ERD viewers +tailor tailordb erd diff --base-html base.html --head-html head.html -o diff.html + +# Deploy the ERD viewer to the static website configured as `erdSite` +tailor tailordb erd deploy --namespace my-db +``` + +`deploy` publishes to the static website referenced by `erdSite` in your `tailor.config.ts`: + +```ts +db: { + "my-db": { + files: ["tailordb/*.ts"], + erdSite: "my-erd-site", + }, +}, +``` + +Run `tailor tailordb erd --help` for the full option reference. diff --git a/packages/sdk-plugin-tailordb-erd/package.json b/packages/sdk-plugin-tailordb-erd/package.json new file mode 100644 index 000000000..486cfd1a7 --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/package.json @@ -0,0 +1,52 @@ +{ + "name": "@tailor-platform/sdk-plugin-tailordb-erd", + "version": "0.1.0-next.1", + "description": "Tailor CLI plugin providing the `tailor tailordb erd` commands (TailorDB ERD viewer)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/tailor-platform/sdk.git", + "directory": "packages/sdk-plugin-tailordb-erd" + }, + "bin": { + "tailor-tailordb-erd": "./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", + "chokidar": "5.0.0", + "mime-types": "3.0.2", + "open": "11.0.0", + "pathe": "2.0.3", + "pkg-types": "2.3.1", + "politty": "0.11.2", + "zod": "4.4.3" + }, + "devDependencies": { + "@tailor-platform/sdk": "workspace:^", + "@types/mime-types": "3.0.1", + "@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/src/cli/commands/tailordb/erd/deploy.ts b/packages/sdk-plugin-tailordb-erd/src/deploy.ts similarity index 68% rename from packages/sdk/src/cli/commands/tailordb/erd/deploy.ts rename to packages/sdk-plugin-tailordb-erd/src/deploy.ts index fc4261e38..085961a97 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/deploy.ts +++ b/packages/sdk-plugin-tailordb-erd/src/deploy.ts @@ -1,26 +1,35 @@ +import { assertWritable, deployStaticWebsite } from "@tailor-platform/sdk/cli"; import { arg } from "politty"; import { z } from "zod"; -import { deploymentArgs } from "#/cli/shared/args"; -import { defineAppCommand } from "#/cli/shared/command"; -import { logger } from "#/cli/shared/logger"; -import { assertWritable } from "#/cli/shared/readonly-guard"; -import { deployStaticWebsite, logSkippedFiles } from "../../staticwebsite/deploy"; import { prepareErdBuilds } from "./export"; +import { deploymentArgs } from "./shared/args"; +import { defineAppCommand } from "./shared/command"; +import { logger } from "./shared/logger"; import { initErdDeployContext } from "./utils"; +function logSkippedFiles(skippedFiles: string[]): void { + if (skippedFiles.length === 0) { + return; + } + logger.warn( + "Deployment completed, but some files failed to upload. These files may have unsupported content types or other validation issues. Please review the list below:", + ); + for (const file of skippedFiles) { + logger.log(` - ${file}`); + } +} + export const erdDeployCommand = defineAppCommand({ name: "deploy", description: "Deploy ERD static website for TailorDB namespace(s).", - args: z - .object({ - ...deploymentArgs, - namespace: arg(z.string().optional(), { - alias: "n", - description: - "TailorDB namespace name (optional - deploys all namespaces with erdSite if omitted)", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + namespace: arg(z.string().optional(), { + alias: "n", + description: + "TailorDB namespace name (optional - deploys all namespaces with erdSite if omitted)", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); const { client, workspaceId } = await initErdDeployContext(args); diff --git a/packages/sdk/src/cli/commands/tailordb/erd/diff-command.test.ts b/packages/sdk-plugin-tailordb-erd/src/diff-command.test.ts similarity index 98% rename from packages/sdk/src/cli/commands/tailordb/erd/diff-command.test.ts rename to packages/sdk-plugin-tailordb-erd/src/diff-command.test.ts index 1bb708935..73588360b 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/diff-command.test.ts +++ b/packages/sdk-plugin-tailordb-erd/src/diff-command.test.ts @@ -14,7 +14,7 @@ function schema(overrides: Partial = {}): TailorDbErdSchema { generatedAt: "2026-01-01T00:00:00.000Z", revision: "revision", source: "local", - cleanRoom: { implementation: "tailor-sdk", notes: [] }, + cleanRoom: { implementation: "tailor", notes: [] }, tables: [], relations: [], ...overrides, diff --git a/packages/sdk/src/cli/commands/tailordb/erd/diff-command.ts b/packages/sdk-plugin-tailordb-erd/src/diff-command.ts similarity index 77% rename from packages/sdk/src/cli/commands/tailordb/erd/diff-command.ts rename to packages/sdk-plugin-tailordb-erd/src/diff-command.ts index 67cd704dd..d04b5887e 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/diff-command.ts +++ b/packages/sdk-plugin-tailordb-erd/src/diff-command.ts @@ -2,8 +2,6 @@ import * as fs from "node:fs"; import * as path from "pathe"; import { arg } from "politty"; import { z } from "zod"; -import { defineAppCommand } from "#/cli/shared/command"; -import { logger } from "#/cli/shared/logger"; import { buildErdDiffViewerSchema, buildErdSchemaDiff, @@ -12,6 +10,8 @@ import { renderErdDiffHtml, type ErdSchemaDiff, } from "./diff"; +import { defineAppCommand } from "./shared/command"; +import { logger } from "./shared/logger"; import { initErdCommand } from "./utils"; import type { TailorDbErdSchema } from "./types"; @@ -96,31 +96,29 @@ export function writeErdDiff(options: WriteErdDiffOptions): WriteErdDiffResult { export const erdDiffCommand = defineAppCommand({ name: "diff", description: "Render TailorDB ERD schema diff HTML from exported ERD viewers.", - args: z - .object({ - "base-html": arg(z.string().optional(), { - description: "Base ERD viewer HTML file", - completion: { type: "file", matcher: [".html"] }, - }), - "head-html": arg(z.string().optional(), { - description: "Head ERD viewer HTML file", - completion: { type: "file", matcher: [".html"] }, - }), - namespace: arg(z.string().optional(), { - alias: "n", - description: "TailorDB namespace name (defaults to the provided ERD schema namespace)", - }), - output: arg(z.string().min(1), { - alias: "o", - description: "Output ERD diff HTML file", - completion: { type: "file", matcher: [".html"] }, - }), - "output-json": arg(z.string().optional(), { - description: "Optional output JSON file for the computed diff", - completion: { type: "file", matcher: [".json"] }, - }), - }) - .strict(), + args: z.strictObject({ + "base-html": arg(z.string().optional(), { + description: "Base ERD viewer HTML file", + completion: { type: "file", matcher: [".html"] }, + }), + "head-html": arg(z.string().optional(), { + description: "Head ERD viewer HTML file", + completion: { type: "file", matcher: [".html"] }, + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "TailorDB namespace name (defaults to the provided ERD schema namespace)", + }), + output: arg(z.string().min(1), { + alias: "o", + description: "Output ERD diff HTML file", + completion: { type: "file", matcher: [".html"] }, + }), + "output-json": arg(z.string().optional(), { + description: "Optional output JSON file for the computed diff", + completion: { type: "file", matcher: [".json"] }, + }), + }), run: (args) => { initErdCommand(); const result = writeErdDiff({ diff --git a/packages/sdk/src/cli/commands/tailordb/erd/diff.test.ts b/packages/sdk-plugin-tailordb-erd/src/diff.test.ts similarity index 99% rename from packages/sdk/src/cli/commands/tailordb/erd/diff.test.ts rename to packages/sdk-plugin-tailordb-erd/src/diff.test.ts index 9990cc373..5e28e792a 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/diff.test.ts +++ b/packages/sdk-plugin-tailordb-erd/src/diff.test.ts @@ -37,7 +37,7 @@ function schema(overrides: Partial = {}): TailorDbErdSchema { generatedAt: "2026-01-01T00:00:00.000Z", revision: "base-revision", source: "local", - cleanRoom: { implementation: "tailor-sdk", notes: [] }, + cleanRoom: { implementation: "tailor", notes: [] }, tables: [table("User")], relations: [], ...overrides, diff --git a/packages/sdk/src/cli/commands/tailordb/erd/diff.ts b/packages/sdk-plugin-tailordb-erd/src/diff.ts similarity index 99% rename from packages/sdk/src/cli/commands/tailordb/erd/diff.ts rename to packages/sdk-plugin-tailordb-erd/src/diff.ts index ff11bdf29..b493d217c 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/diff.ts +++ b/packages/sdk-plugin-tailordb-erd/src/diff.ts @@ -323,7 +323,7 @@ export function createEmptyErdSchema(options: CreateEmptyErdSchemaOptions): Tail revision: options.revision, source: "local", cleanRoom: { - implementation: "tailor-sdk", + implementation: "tailor", notes: ["Synthetic empty schema used for ERD diff generation."], }, tables: [], diff --git a/packages/sdk/src/cli/commands/tailordb/erd/export.ts b/packages/sdk-plugin-tailordb-erd/src/export.ts similarity index 88% rename from packages/sdk/src/cli/commands/tailordb/erd/export.ts rename to packages/sdk-plugin-tailordb-erd/src/export.ts index b8372a808..675628f73 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/export.ts +++ b/packages/sdk-plugin-tailordb-erd/src/export.ts @@ -1,16 +1,16 @@ import * as path from "pathe"; import { arg } from "politty"; import { z } from "zod"; -import { configArg } from "#/cli/shared/args"; -import { defineAppCommand } from "#/cli/shared/command"; -import { logger } from "#/cli/shared/logger"; import { loadLocalErdSchema, type LocalErdSchemaContext } from "./local-schema"; import { buildTailorDbErdSchema } from "./schema"; +import { configArg } from "./shared/args"; +import { defineAppCommand } from "./shared/command"; +import { logger } from "./shared/logger"; import { initErdCommand } from "./utils"; import { writeViewerDist } from "./viewer"; -import type { TailorDBNamespaceData } from "#/plugin/types"; +import type { TailorDBNamespaceData } from "@tailor-platform/sdk/cli"; -const DEFAULT_ERD_BASE_DIR = ".tailor-sdk/erd"; +const DEFAULT_ERD_BASE_DIR = ".tailor/erd"; interface ResolveTargetsOptions { context: LocalErdSchemaContext; @@ -177,22 +177,19 @@ export function prepareErdBuildsFromContext( export const erdExportCommand = defineAppCommand({ name: "export", description: "Export TailorDB ERD static viewer from local TailorDB schema.", - args: z - .object({ - ...configArg, - namespace: arg(z.string().optional(), { - alias: "n", - description: - "TailorDB namespace name (optional if only one namespace is defined in config)", - }), - output: arg(z.string().default(DEFAULT_ERD_BASE_DIR), { - alias: "o", - description: - "Output directory path for TailorDB ERD viewer files (writes to `//dist`)", - completion: { type: "directory" }, - }), - }) - .strict(), + args: z.strictObject({ + ...configArg, + namespace: arg(z.string().optional(), { + alias: "n", + description: "TailorDB namespace name (optional if only one namespace is defined in config)", + }), + output: arg(z.string().default(DEFAULT_ERD_BASE_DIR), { + alias: "o", + description: + "Output directory path for TailorDB ERD viewer files (writes to `//dist`)", + completion: { type: "directory" }, + }), + }), run: async (args) => { initErdCommand(); diff --git a/packages/sdk-plugin-tailordb-erd/src/index.ts b/packages/sdk-plugin-tailordb-erd/src/index.ts new file mode 100644 index 000000000..7822bef77 --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/src/index.ts @@ -0,0 +1,58 @@ +#!/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 { erdDeployCommand } from "./deploy"; +import { erdDiffCommand } from "./diff-command"; +import { erdExportCommand } from "./export"; +import { erdServeCommand } from "./serve"; +import { commonArgs } from "./shared/args"; +import { logger } from "./shared/logger"; + +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-tailordb-erd", + description: + "Generate TailorDB ERD viewer artifacts from local TailorDB schema. (beta)\n" + + "Tailor CLI plugin: installed alongside the Tailor CLI, it runs as `tailor tailordb erd `.", + subCommands: { + export: erdExportCommand, + diff: erdDiffCommand, + serve: erdServeCommand, + deploy: erdDeployCommand, + }, +}); + +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/src/cli/commands/tailordb/erd/local-schema.test.ts b/packages/sdk-plugin-tailordb-erd/src/local-schema.test.ts similarity index 94% rename from packages/sdk/src/cli/commands/tailordb/erd/local-schema.test.ts rename to packages/sdk-plugin-tailordb-erd/src/local-schema.test.ts index e1797bc63..e9f9d48b1 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/local-schema.test.ts +++ b/packages/sdk-plugin-tailordb-erd/src/local-schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { resolveLocalErdSchemaNamespaces } from "./local-schema"; -import type { LoadedConfig } from "#/cli/shared/config-loader"; +import type { LoadedConfig } from "@tailor-platform/sdk/cli"; describe("resolveLocalErdSchemaNamespaces", () => { const config = { diff --git a/packages/sdk-plugin-tailordb-erd/src/local-schema.ts b/packages/sdk-plugin-tailordb-erd/src/local-schema.ts new file mode 100644 index 000000000..514b550cd --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/src/local-schema.ts @@ -0,0 +1,60 @@ +import { loadTailorDBNamespaces } from "@tailor-platform/sdk/cli"; +import type { LoadedConfig, TailorDBNamespaceData } from "@tailor-platform/sdk/cli"; + +export interface LoadLocalErdSchemaOptions { + configPath?: string; + namespaces?: string[]; + requireErdSite?: boolean; +} + +export interface LocalErdSchemaContext { + config: LoadedConfig; + namespaces: TailorDBNamespaceData[]; +} + +export interface ResolveLocalErdSchemaNamespacesOptions { + /** Explicit namespace selection. */ + namespaces?: string[]; + /** Limit implicit selection to owned namespaces with erdSite configured. */ + requireErdSite?: boolean; +} + +/** + * Resolve TailorDB namespaces that need local type loading for ERD generation. + * @param config - Loaded Tailor config. + * @param options - Namespace selection options. + * @returns Namespace names to load, or undefined to load all owned namespaces. + */ +export function resolveLocalErdSchemaNamespaces( + config: LoadedConfig, + options: ResolveLocalErdSchemaNamespacesOptions, +): string[] | undefined { + if (options.namespaces) { + return options.namespaces; + } + if (!options.requireErdSite) { + return undefined; + } + + return Object.entries(config.db ?? {}).flatMap(([namespace, dbConfig]) => + "external" in dbConfig || !dbConfig.erdSite ? [] : [namespace], + ); +} + +/** + * Load local TailorDB namespaces exactly as SDK generation/deploy sees them. + * @param options - Local schema loading options. + * @returns Loaded TailorDB namespace data. + */ +export async function loadLocalErdSchema( + options: LoadLocalErdSchemaOptions, +): Promise { + return await loadTailorDBNamespaces({ + configPath: options.configPath, + namespaces: (config) => + resolveLocalErdSchemaNamespaces(config, { + namespaces: options.namespaces, + requireErdSite: options.requireErdSite, + }), + }); +} diff --git a/packages/sdk/src/cli/commands/tailordb/erd/schema.test.ts b/packages/sdk-plugin-tailordb-erd/src/schema.test.ts similarity index 98% rename from packages/sdk/src/cli/commands/tailordb/erd/schema.test.ts rename to packages/sdk-plugin-tailordb-erd/src/schema.test.ts index 85d7e07dc..7e9d89ab3 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/schema.test.ts +++ b/packages/sdk-plugin-tailordb-erd/src/schema.test.ts @@ -6,9 +6,9 @@ import { buildTailorDbErdSchema, writeTailorDbErdSchemaToFile } from "./schema"; import type { OperatorFieldConfig, ParsedField, + TailorDBNamespaceData, TailorDBType, -} from "#/parser/service/tailordb/types"; -import type { TailorDBNamespaceData } from "#/plugin/types"; +} from "@tailor-platform/sdk/cli"; function createField( name: string, diff --git a/packages/sdk/src/cli/commands/tailordb/erd/schema.ts b/packages/sdk-plugin-tailordb-erd/src/schema.ts similarity index 96% rename from packages/sdk/src/cli/commands/tailordb/erd/schema.ts rename to packages/sdk-plugin-tailordb-erd/src/schema.ts index 5c91aa04d..1c4b49e35 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/schema.ts +++ b/packages/sdk-plugin-tailordb-erd/src/schema.ts @@ -1,15 +1,8 @@ +import * as crypto from "node:crypto"; import * as fs from "node:fs"; +import { isPluginGeneratedType } from "@tailor-platform/sdk/cli"; import * as path from "pathe"; -import { hashContent } from "#/cli/cache/hasher"; -import { logger } from "#/cli/shared/logger"; -import { isPluginGeneratedType } from "#/parser/service/tailordb/type-source"; -import type { - OperatorFieldConfig, - ParsedField, - TailorDBType, - TypeSourceInfoEntry, -} from "#/parser/service/tailordb/types"; -import type { TailorDBNamespaceData } from "#/plugin/types"; +import { logger } from "./shared/logger"; import type { TailorDbErdColumn, TailorDbErdColumnRelation, @@ -21,6 +14,13 @@ import type { TailorDbErdTable, TailorDbErdTypeSource, } from "./types"; +import type { + OperatorFieldConfig, + ParsedField, + TailorDBNamespaceData, + TailorDBType, + TypeSourceInfoEntry, +} from "@tailor-platform/sdk/cli"; const CLEAN_ROOM_NOTES = [ "Generated by a TailorDB-specific viewer implementation.", @@ -47,7 +47,11 @@ interface BuildColumnOptions { } function buildRevision(schema: Omit): string { - return hashContent(JSON.stringify(schema)).slice(0, 16); + return crypto + .createHash("sha256") + .update(JSON.stringify(schema), "utf-8") + .digest("hex") + .slice(0, 16); } function toTypeSource(source: TypeSourceInfoEntry | undefined): TailorDbErdTypeSource | undefined { @@ -260,7 +264,7 @@ export function buildTailorDbErdSchema(options: BuildTailorDbErdSchemaOptions): namespace: namespaceData.namespace, source: options.source ?? "local", cleanRoom: { - implementation: "tailor-sdk" as const, + implementation: "tailor" as const, notes: CLEAN_ROOM_NOTES, }, tables, diff --git a/packages/sdk/src/cli/commands/tailordb/erd/serve.test.ts b/packages/sdk-plugin-tailordb-erd/src/serve.test.ts similarity index 100% rename from packages/sdk/src/cli/commands/tailordb/erd/serve.test.ts rename to packages/sdk-plugin-tailordb-erd/src/serve.test.ts diff --git a/packages/sdk/src/cli/commands/tailordb/erd/serve.ts b/packages/sdk-plugin-tailordb-erd/src/serve.ts similarity index 93% rename from packages/sdk/src/cli/commands/tailordb/erd/serve.ts rename to packages/sdk-plugin-tailordb-erd/src/serve.ts index d184bc12e..e9d402769 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/serve.ts +++ b/packages/sdk-plugin-tailordb-erd/src/serve.ts @@ -2,21 +2,21 @@ import { spawn } from "node:child_process"; import * as fs from "node:fs"; import { glob } from "node:fs/promises"; import * as http from "node:http"; +import { loadConfig, type LoadedConfig } from "@tailor-platform/sdk/cli"; import { watch, type FSWatcher } from "chokidar"; import { lookup as lookupMime } from "mime-types"; import open from "open"; import * as path from "pathe"; import { arg } from "politty"; import { z } from "zod"; -import { configArg } from "#/cli/shared/args"; -import { defineAppCommand } from "#/cli/shared/command"; -import { loadConfig, type LoadedConfig } from "#/cli/shared/config-loader"; -import { logger } from "#/cli/shared/logger"; import { prepareErdBuildsFromContext, type ErdBuildResult } from "./export"; import { loadLocalErdSchema, type LocalErdSchemaContext } from "./local-schema"; +import { configArg } from "./shared/args"; +import { defineAppCommand } from "./shared/command"; +import { logger } from "./shared/logger"; import { initErdCommand } from "./utils"; -const DEFAULT_ERD_BASE_DIR = ".tailor-sdk/erd"; +const DEFAULT_ERD_BASE_DIR = ".tailor/erd"; const LOCAL_HOST = "127.0.0.1"; interface StaticServerResult { @@ -56,7 +56,7 @@ interface OpenStaticFileResult { const GLOB_CHARS = /[*?[\]{}()!+@]/; function formatServeCommand(namespace: string): string { - return `tailor-sdk tailordb erd serve --namespace ${namespace}`; + return `tailor tailordb erd serve --namespace ${namespace}`; } function getCacheControl(filePath: string): string { @@ -272,7 +272,7 @@ function freshErdExportArgs(options: FreshErdExportOptions): string[] { throw new Error("Cannot rebuild ERD schema in a fresh process: CLI entrypoint is unavailable."); } - const args = [cliEntry, "tailordb", "erd", "export", "--output", options.outputDir, "--json"]; + const args = [cliEntry, "export", "--output", options.outputDir, "--json"]; if (options.configPath) { args.push("--config", options.configPath); } @@ -427,21 +427,19 @@ async function waitForShutdown(server: http.Server, watcher: FSWatcher): Promise export const erdServeCommand = defineAppCommand({ name: "serve", description: "Generate and serve TailorDB ERD locally with watch reload. (beta)", - args: z - .object({ - ...configArg, - namespace: arg(z.string().optional(), { - alias: "n", - description: "TailorDB namespace name (uses first namespace in config if not specified)", - }), - port: arg(z.coerce.number().int().min(0).max(65535).default(0), { - description: "Local server port (0 selects a free port)", - }), - open: arg(z.boolean().default(false), { - description: "Open the ERD viewer in the default browser", - }), - }) - .strict(), + args: z.strictObject({ + ...configArg, + namespace: arg(z.string().optional(), { + alias: "n", + description: "TailorDB namespace name (uses first namespace in config if not specified)", + }), + port: arg(z.coerce.number().int().min(0).max(65535).default(0), { + description: "Local server port (0 selects a free port)", + }), + open: arg(z.boolean().default(false), { + description: "Open the ERD viewer in the default browser", + }), + }), run: async (args) => { initErdCommand(); diff --git a/packages/sdk-plugin-tailordb-erd/src/shared/args.ts b/packages/sdk-plugin-tailordb-erd/src/shared/args.ts new file mode 100644 index 000000000..5ed9b5dee --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/src/shared/args.ts @@ -0,0 +1,119 @@ +import * as fs from "node:fs"; +import { parseEnv } from "node:util"; +import * as path from "pathe"; +import { arg } from "politty"; +import { z } from "zod"; +import { logger } from "./logger"; + +type ArgsShape = Record; + +type EnvFileArg = string | string[] | undefined; + +/** + * Load env files from parsed arguments, following Node.js --env-file behavior: + * variables already set in the environment are not overwritten, and variables + * from later files override those from earlier files. + * @param envFiles - Required env file path(s) that must exist + * @param envFilesIfExists - Optional env file path(s) that are loaded if they exist + */ +function loadEnvFiles(envFiles: EnvFileArg, envFilesIfExists: EnvFileArg): void { + const originalEnvKeys = new Set(Object.keys(process.env)); + + const load = (files: EnvFileArg, required: boolean) => { + for (const file of [files ?? []].flat()) { + const envPath = path.resolve(process.cwd(), file); + if (!fs.existsSync(envPath)) { + if (required) { + throw new Error(`Environment file not found: ${envPath}`); + } + continue; + } + const content = fs.readFileSync(envPath, "utf-8"); + const parsed = parseEnv(content); + for (const [key, value] of Object.entries(parsed)) { + if (originalEnvKeys.has(key)) { + continue; + } + process.env[key] = value; + } + } + }; + + load(envFiles, true); + load(envFilesIfExists, false); +} + +/** + * Common arguments shared with the host Tailor CLI so that forwarded global + * flags parse identically when dispatched as a plugin. + */ +export const commonArgs = { + "env-file": arg(z.string().optional(), { + alias: "e", + description: "Path to the environment file (error if not found)", + completion: { type: "file", matcher: [".env.*", ".env"] }, + }), + "env-file-if-exists": arg(z.string().optional(), { + description: "Path to the environment file (ignored if not found)", + completion: { type: "file", matcher: [".env.*", ".env"] }, + effect: (_value, { args }) => { + loadEnvFiles( + args["env-file"] as string | undefined, + args["env-file-if-exists"] as string | undefined, + ); + }, + }), + verbose: arg(z.boolean().default(false), { + description: "Enable verbose logging", + effect: (value) => { + logger.verbose = value; + }, + }), + json: arg(z.boolean().default(false), { + alias: "j", + description: "Output as JSON", + effect: (value) => { + logger.jsonMode = value; + }, + }), +} satisfies ArgsShape; + +/** + * Arguments for commands that require workspace context + */ +export const workspaceArgs = { + "workspace-id": arg(z.string().optional(), { + alias: "w", + description: "Workspace ID", + env: "TAILOR_PLATFORM_WORKSPACE_ID", + completion: { type: "none" }, + }), + profile: arg(z.string().optional(), { + alias: "p", + description: "Workspace profile", + env: "TAILOR_PLATFORM_PROFILE", + completion: { type: "none" }, + }), +} satisfies ArgsShape; + +/** + * Shared config arg for commands that accept a config file path + */ +export const configArg = { + config: arg(z.string().default("tailor.config.ts"), { + alias: "c", + description: "Path to Tailor config file", + env: "TAILOR_CONFIG_PATH", + completion: { type: "file", extensions: ["ts"] }, + }), +} satisfies ArgsShape; + +/** + * Arguments for commands that interact with deployed resources (includes config) + */ +export const deploymentArgs = { + ...workspaceArgs, + ...configArg, +} satisfies ArgsShape; + +export type CommonArgsType = z.infer>; diff --git a/packages/sdk-plugin-tailordb-erd/src/shared/command.ts b/packages/sdk-plugin-tailordb-erd/src/shared/command.ts new file mode 100644 index 000000000..ac63dfb59 --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/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-tailordb-erd/src/shared/logger.ts b/packages/sdk-plugin-tailordb-erd/src/shared/logger.ts new file mode 100644 index 000000000..24c4e6f17 --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/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/src/cli/commands/tailordb/erd/types.ts b/packages/sdk-plugin-tailordb-erd/src/types.ts similarity index 98% rename from packages/sdk/src/cli/commands/tailordb/erd/types.ts rename to packages/sdk-plugin-tailordb-erd/src/types.ts index f7f6bcba3..e89c0e0ad 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/types.ts +++ b/packages/sdk-plugin-tailordb-erd/src/types.ts @@ -96,7 +96,7 @@ export interface TailorDbErdSchema { revision: string; source: TailorDbErdSource; cleanRoom: { - implementation: "tailor-sdk"; + implementation: "tailor"; notes: string[]; }; tables: TailorDbErdTable[]; diff --git a/packages/sdk/src/cli/commands/tailordb/erd/utils.ts b/packages/sdk-plugin-tailordb-erd/src/utils.ts similarity index 69% rename from packages/sdk/src/cli/commands/tailordb/erd/utils.ts rename to packages/sdk-plugin-tailordb-erd/src/utils.ts index 9234dd501..acbf0511c 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/utils.ts +++ b/packages/sdk-plugin-tailordb-erd/src/utils.ts @@ -1,7 +1,6 @@ -import { logBetaWarning } from "#/cli/shared/beta"; -import { initOperatorClient } from "#/cli/shared/client"; -import { loadAccessToken, loadWorkspaceId } from "#/cli/shared/context"; -import type { OperatorClient } from "#/cli/shared/client"; +import { initOperatorClient, loadAccessToken, loadWorkspaceId } from "@tailor-platform/sdk/cli"; +import { logger } from "./shared/logger"; +import type { OperatorClient } from "@tailor-platform/sdk/cli"; export interface ErdDeployContext { client: OperatorClient; @@ -17,7 +16,10 @@ type ErdDeployContextOptions = { * Initialize shared ERD command behavior. */ export function initErdCommand(): void { - logBetaWarning("tailordb erd"); + logger.warn( + "The 'tailordb erd' command is a beta feature and may introduce breaking changes in future releases.", + ); + logger.newline(); } /** diff --git a/packages/sdk/src/cli/commands/tailordb/erd/viewer-assets/app.js b/packages/sdk-plugin-tailordb-erd/src/viewer-assets/app.js similarity index 100% rename from packages/sdk/src/cli/commands/tailordb/erd/viewer-assets/app.js rename to packages/sdk-plugin-tailordb-erd/src/viewer-assets/app.js diff --git a/packages/sdk/src/cli/commands/tailordb/erd/viewer-assets/index.html b/packages/sdk-plugin-tailordb-erd/src/viewer-assets/index.html similarity index 100% rename from packages/sdk/src/cli/commands/tailordb/erd/viewer-assets/index.html rename to packages/sdk-plugin-tailordb-erd/src/viewer-assets/index.html diff --git a/packages/sdk/src/cli/commands/tailordb/erd/viewer-assets/serve.json b/packages/sdk-plugin-tailordb-erd/src/viewer-assets/serve.json similarity index 100% rename from packages/sdk/src/cli/commands/tailordb/erd/viewer-assets/serve.json rename to packages/sdk-plugin-tailordb-erd/src/viewer-assets/serve.json diff --git a/packages/sdk/src/cli/commands/tailordb/erd/viewer-assets/styles.css b/packages/sdk-plugin-tailordb-erd/src/viewer-assets/styles.css similarity index 100% rename from packages/sdk/src/cli/commands/tailordb/erd/viewer-assets/styles.css rename to packages/sdk-plugin-tailordb-erd/src/viewer-assets/styles.css diff --git a/packages/sdk/src/cli/commands/tailordb/erd/viewer.test.ts b/packages/sdk-plugin-tailordb-erd/src/viewer.test.ts similarity index 98% rename from packages/sdk/src/cli/commands/tailordb/erd/viewer.test.ts rename to packages/sdk-plugin-tailordb-erd/src/viewer.test.ts index 9578f37c3..a4720bf90 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/viewer.test.ts +++ b/packages/sdk-plugin-tailordb-erd/src/viewer.test.ts @@ -9,7 +9,7 @@ function buildSchema(overrides: Partial = {}): TailorDbErdSch generatedAt: "2026-01-01T00:00:00.000Z", revision: "test-revision", source: "local", - cleanRoom: { implementation: "tailor-sdk", notes: [] }, + cleanRoom: { implementation: "tailor", notes: [] }, tables: [ { name: "User", diff --git a/packages/sdk/src/cli/commands/tailordb/erd/viewer.ts b/packages/sdk-plugin-tailordb-erd/src/viewer.ts similarity index 92% rename from packages/sdk/src/cli/commands/tailordb/erd/viewer.ts rename to packages/sdk-plugin-tailordb-erd/src/viewer.ts index e03dcd1a5..3c793f7d2 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/viewer.ts +++ b/packages/sdk-plugin-tailordb-erd/src/viewer.ts @@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url"; import * as path from "pathe"; import type { TailorDbErdSchema } from "./types"; -const VIEWER_ASSETS_DIR = "erd-viewer-assets"; const STYLES_LINK = ''; const APP_SCRIPT = ''; @@ -29,12 +28,7 @@ function escapeHtml(value: string): string { function assetDirCandidates(): string[] { const currentDir = path.dirname(fileURLToPath(import.meta.url)); - return [ - path.join(currentDir, "viewer-assets"), - path.join(currentDir, VIEWER_ASSETS_DIR), - path.join(currentDir, "commands", "tailordb", "erd", VIEWER_ASSETS_DIR), - path.resolve(process.cwd(), "packages/sdk/src/cli/commands/tailordb/erd/viewer-assets"), - ]; + return [path.join(currentDir, "viewer-assets")]; } /** diff --git a/packages/sdk-plugin-tailordb-erd/tsconfig.json b/packages/sdk-plugin-tailordb-erd/tsconfig.json new file mode 100644 index 000000000..4bafc4f43 --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/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-tailordb-erd/tsdown.config.ts b/packages/sdk-plugin-tailordb-erd/tsdown.config.ts new file mode 100644 index 000000000..62882d5ea --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/tsdown.config.ts @@ -0,0 +1,22 @@ +import { cpSync, rmSync } from "node:fs"; +import path from "node:path"; +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", + }), + onSuccess: (config) => { + const target = path.resolve(config.outDir, "viewer-assets"); + rmSync(target, { recursive: true, force: true }); + cpSync(path.resolve("src/viewer-assets"), target, { recursive: true }); + }, +}); diff --git a/packages/sdk-plugin-tailordb-erd/vitest.config.ts b/packages/sdk-plugin-tailordb-erd/vitest.config.ts new file mode 100644 index 000000000..7878e9578 --- /dev/null +++ b/packages/sdk-plugin-tailordb-erd/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/.oxlintrc.json b/packages/sdk/.oxlintrc.json index 71a2dbfdb..670e52388 100644 --- a/packages/sdk/.oxlintrc.json +++ b/packages/sdk/.oxlintrc.json @@ -11,7 +11,7 @@ "dist/", "src/types/*.generated.ts", "e2e/fixtures/", - ".tailor-sdk/", + ".tailor/", "tailor.d.ts", "plugin-defined.d.ts", "**/__test_fixtures__/dist/", @@ -89,7 +89,10 @@ "typescript/no-unnecessary-type-constraint": "error", "typescript/no-unsafe-function-type": "error", "local/no-deprecated-type-matcher": "error", - "local/require-param-strict": "error" + "local/require-param-strict": "error", + "tailor-zod/require-object-policy-comment": "error", + "zod/prefer-loose-object": "error", + "zod/prefer-strict-object": "error" }, "overrides": [ { @@ -466,5 +469,9 @@ "options": { "typeAware": true }, - "jsPlugins": ["./oxlint-plugins/index.js"] + "jsPlugins": [ + "./oxlint-plugins/index.js", + "eslint-plugin-zod", + "../../scripts/oxlint/tailor-zod-plugin.cjs" + ] } diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 7ef038b6b..6a1eea007 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -1,17 +1,376 @@ # @tailor-platform/sdk -## 1.79.0 +## 2.0.0-next.8 + +### Patch Changes + +- [#1808](https://github.com/tailor-platform/sdk/pull/1808) [`61ac76c`](https://github.com/tailor-platform/sdk/commit/61ac76cff992ef30e6115ba96c7d3a676010204f) Thanks [@toiroakr](https://github.com/toiroakr)! - Fix IdP and TailorDB permission condition types breaking when `Attributes` fields are optional. Since machine user attribute keys started mirroring the source field's optionality, the `user` operand key helpers leaked `undefined` into their key unions — failing typecheck against the generated permission types even for `_loggedIn`-only conditions — and rejected attribute keys derived from optional fields. Optional attribute fields are now valid operand keys and `undefined` no longer appears in the unions. + +## 2.0.0-next.7 + +### Major Changes + +- [#1782](https://github.com/tailor-platform/sdk/pull/1782) [`c971797`](https://github.com/tailor-platform/sdk/commit/c971797c9bfa035a43771c46f2b1c3bd93f989a9) Thanks [@toiroakr](https://github.com/toiroakr)! - Rename `Workflow.trigger()` (returned by `createWorkflow()`) and `WorkflowJob.trigger()` (returned by `createWorkflowJob()`) to `.start()`, aligning the SDK's ergonomic verb with the platform's `start*` RPC vocabulary: + + ```diff + const inventory = checkInventory.trigger({ orderId: input.orderId }); + +const inventory = checkInventory.start({ orderId: input.orderId }); + + -const workflowRunId = await orderProcessingWorkflow.trigger(args, { invoker: "manager" }); + +const workflowRunId = await orderProcessingWorkflow.start(args, { invoker: "manager" }); + ``` + + `mockWorkflow()`'s `wf.job(definition)` / `wf.workflow(definition)` now return a mock of the `.start` method, and `wf.setTriggerHandler` / `wf.triggeredJobs` are renamed to `wf.setStartHandler` / `wf.startedJobs`. No codemod ships for the `.trigger()` → `.start()` call-site rename itself — see the `v2/workflow-start-rename` migration guide entry for manual migration steps. + +- [#1782](https://github.com/tailor-platform/sdk/pull/1782) [`c971797`](https://github.com/tailor-platform/sdk/commit/c971797c9bfa035a43771c46f2b1c3bd93f989a9) Thanks [@toiroakr](https://github.com/toiroakr)! - Remove the pre-alignment `tailor.workflow` names `triggerWorkflow`, `triggerJobFunction`, and `resumeWorkflow` (and their `TriggerWorkflowOptions` / `TriggerJobFunctionOptions` option types) from `@tailor-platform/sdk/runtime`, the ambient `@tailor-platform/sdk/runtime/globals` types, and the `mockWorkflow()` test facade. Use the canonical names instead: + + ```diff + import { workflow } from "@tailor-platform/sdk/runtime"; + + -await workflow.triggerWorkflow("myWorkflow", { data: "value" }); + +await workflow.startWorkflow("myWorkflow", { data: "value" }); + -workflow.triggerJobFunction("myJob", { data: "value" }); + +workflow.startJobFunction("myJob", { data: "value" }); + -await workflow.resumeWorkflow("execution-id"); + +await workflow.resumeWorkflowExecution("execution-id"); + ``` + + Run the `v2/workflow-trigger-rename` codemod to migrate call sites automatically. + +### Minor Changes + +- [#1800](https://github.com/tailor-platform/sdk/pull/1800) [`d07a82a`](https://github.com/tailor-platform/sdk/commit/d07a82aa4ded74c3d84e157b4bed5c37ef0ec239) Thanks [@toiroakr](https://github.com/toiroakr)! - Machine user attribute keys now mirror the field's optionality: attributes derived from optional user fields (or optional `machineUserAttributes` fields) can be omitted, and `null`/`undefined` values are treated as "attribute not set" instead of being rejected at deploy time. Attributes derived from required fields remain mandatory, and undeclared attribute keys are still rejected. The generated `AttributeMap` type used to read `user.attributes` in resolvers, executors, and workflows now mirrors this same optionality, so an attribute derived from an optional field is typed as possibly absent instead of always present. + +### Patch Changes + +- [#1741](https://github.com/tailor-platform/sdk/pull/1741) [`f1cbda5`](https://github.com/tailor-platform/sdk/commit/f1cbda56df96670f18dccf2b7f2473430584f377) Thanks [@toiroakr](https://github.com/toiroakr)! - Resolve each config's `files` glob patterns and bundler `tsconfig` relative to that config file's own directory instead of the invocation `cwd`, so `--config a/tailor.config.ts,b/tailor.config.ts` no longer lets one app's file glob or path aliases bleed into another. If a `files` pattern matches nothing under the new directory, it falls back to resolving against `cwd` as before, so existing configs whose patterns were written against the invocation directory keep working. + +- [#1767](https://github.com/tailor-platform/sdk/pull/1767) [`c870196`](https://github.com/tailor-platform/sdk/commit/c8701961f90d7bdcc887c793c806d4f26cc9b197) Thanks [@toiroakr](https://github.com/toiroakr)! - Fix tsconfig `paths` alias resolution for dynamically loaded resolver, executor, workflow, HTTP adapter, and TailorDB type files. Previously, an import like `import { foo } from "@/utils"` in one of these files would fail to resolve when the file lived outside the directory tsx was registered from (e.g. in multi-app setups). Each file's `paths` aliases are now resolved as a fallback from its own tsconfig, based on the importing file's own directory. + +- [#1788](https://github.com/tailor-platform/sdk/pull/1788) [`da7d0c4`](https://github.com/tailor-platform/sdk/commit/da7d0c49322deebc9343dee88652152620a7cef9) Thanks [@toiroakr](https://github.com/toiroakr)! - `tailor deploy` no longer automatically deletes on-disk bundle artifacts left by SDK versions predating the in-memory bundling approach; delete those specific stale files manually if any remain from a very old SDK version (do not delete the whole output directory, which also holds deploy state such as secrets and Auth Connection records) + +- [#1785](https://github.com/tailor-platform/sdk/pull/1785) [`cb97bd4`](https://github.com/tailor-platform/sdk/commit/cb97bd45314c5897818233dc8bc3b84b83bea8a3) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency tsx to v4.23.1 + +- [#1801](https://github.com/tailor-platform/sdk/pull/1801) [`ee382c7`](https://github.com/tailor-platform/sdk/commit/ee382c7d5f5c0a14acf47c1dee6f12d8cecad92d) Thanks [@toiroakr](https://github.com/toiroakr)! - Update the `tailordb erd` plugin install hint to reference the renamed `@tailor-platform/sdk-plugin-tailordb-erd` package. + +## 2.0.0-next.6 + +### Major Changes + +- [#1776](https://github.com/tailor-platform/sdk/pull/1776) [`7338457`](https://github.com/tailor-platform/sdk/commit/733845732fabff504c32b725f6919be6167bc7a1) Thanks [@dqn](https://github.com/dqn)! - Remove the built-in `tailordb erd` commands. They are now provided by the `@tailor-platform/sdk-tailordb-erd-plugin` CLI plugin: install it with `npm install -D @tailor-platform/sdk-tailordb-erd-plugin` and keep running `tailor tailordb erd ` as before — the CLI dispatches to the plugin automatically and suggests the install command when the plugin is missing. The `erdSite` TailorDB setting is unchanged. + +- [#1789](https://github.com/tailor-platform/sdk/pull/1789) [`36ad006`](https://github.com/tailor-platform/sdk/commit/36ad006f9eb2d23b21b2028741dbce1d6ba9f91b) Thanks [@toiroakr](https://github.com/toiroakr)! - Remove the `generate --watch` (`-W`) flag. `tailor generate` now always runs a single generation pass; re-run the command after making changes instead of relying on the watcher to regenerate automatically. + +- [#1733](https://github.com/tailor-platform/sdk/pull/1733) [`09f5691`](https://github.com/tailor-platform/sdk/commit/09f5691ef5a76761812f039d125d33eb3211994a) Thanks [@dqn](https://github.com/dqn)! - Require workflow executor `args` when the target workflow input is required, and accept primitive and array static JSON inputs during configuration parsing. Existing workflow executor configurations that omit required input must add `args`. ### Minor Changes +- [#1776](https://github.com/tailor-platform/sdk/pull/1776) [`7338457`](https://github.com/tailor-platform/sdk/commit/733845732fabff504c32b725f6919be6167bc7a1) Thanks [@dqn](https://github.com/dqn)! - Add `loadTailorDBNamespaces`, `deployStaticWebsite`, `assertWritable`, and `isPluginGeneratedType` (with their related types) to `@tailor-platform/sdk/cli`, so CLI plugins and scripts can load local TailorDB schema data and deploy static websites through the public API. + - [#1768](https://github.com/tailor-platform/sdk/pull/1768) [`813f86e`](https://github.com/tailor-platform/sdk/commit/813f86eb0b0df1a768db5de6a39a550d6633749a) Thanks [@k1LoW](https://github.com/k1LoW)! - Add canonical `tailor.workflow.*` names that mirror the public `tailor.v1` RPC vocabulary. The `startWorkflow`, `startJobFunction`, and `resumeWorkflowExecution` methods are now available on `tailor.workflow`, alongside `workflow.startWorkflow` / `workflow.startJobFunction` / `workflow.resumeWorkflowExecution` from `@tailor-platform/sdk/runtime`. The pre-alignment names (`triggerWorkflow`, `triggerJobFunction`, `resumeWorkflow`) continue to work as frozen aliases, but are now marked `@deprecated` so IDEs surface a hint to migrate to the canonical names. ### Patch Changes - [#1769](https://github.com/tailor-platform/sdk/pull/1769) [`9837182`](https://github.com/tailor-platform/sdk/commit/983718288eab23a4c15762c179a67569dd78a287) Thanks [@dqn](https://github.com/dqn)! - Fix `tailor function test-run` crashing with `TypeError: Cannot convert undefined or null to object` when `--arg` is a non-object JSON value such as `null`. The argument is now forwarded to the server, which reports the validation error. Local input validation also runs the same logic as deployed resolvers. +- [#1780](https://github.com/tailor-platform/sdk/pull/1780) [`1316447`](https://github.com/tailor-platform/sdk/commit/13164471cc934da03dd64640b2fb2457c79b4413) Thanks [@toiroakr](https://github.com/toiroakr)! - Fix a spurious `TS2719` type error when a `db.table()` type built with custom fields is passed across module boundaries (e.g. into another package's factory function) and its `.validate()` callback's `issues()` field parameter is compared for assignability. The `issues()` field argument type no longer uses a self-generic parameter, so structurally-equal `TailorDBType` instances derived through different generic instantiation paths are recognized as compatible. + +- [#1781](https://github.com/tailor-platform/sdk/pull/1781) [`000db7e`](https://github.com/tailor-platform/sdk/commit/000db7ef91b699918a8da600faa183ebcb40ba7c) Thanks [@dqn](https://github.com/dqn)! - Prevent concurrent multi-config deployments from mixing resolver, executor, workflow, Auth hook, HTTP adapter, and TailorDB hook or validator bundles + - [#1777](https://github.com/tailor-platform/sdk/pull/1777) [`322b69c`](https://github.com/tailor-platform/sdk/commit/322b69c843953551ccb4b32d7cbd528ae2b0e10c) Thanks [@toiroakr](https://github.com/toiroakr)! - Fail fast at `generate`/`deploy` time when a TailorDB type is missing `.permission()`, or missing `.gqlPermission()` while GraphQL operations are enabled for it (`.gqlPermission()` is not required when GraphQL exposure is fully disabled via `gqlOperations`). Previously these omissions deployed silently and only surfaced later as an opaque `internal error` on insert. +## 2.0.0-next.5 + +### Major Changes + +- [#1719](https://github.com/tailor-platform/sdk/pull/1719) [`4a05aec`](https://github.com/tailor-platform/sdk/commit/4a05aecfb100a1ea7292a6ae5809a2d1e6eddbfe) Thanks [@dqn](https://github.com/dqn)! - Derive default TailorDB forward relation names from the relation field name by removing a trailing `ID`, `Id`, or `id`, instead of deriving them from the target table name. + + The v2 migration review identifies non-self relations without `toward.as`. Add an explicit name to preserve the v1 GraphQL field name, or update consumers to use the new field-based name. + +- [#1678](https://github.com/tailor-platform/sdk/pull/1678) [`e0e768d`](https://github.com/tailor-platform/sdk/commit/e0e768d77470d13806ed7b2ee2117fe374d51d40) Thanks [@toiroakr](https://github.com/toiroakr)! - Redesign TailorDB hooks and validators with several breaking changes: + + - Add shared `now` timestamp to all hooks — multiple fields stamped with the same `Date` + - Field-level hooks: `{ value, data, invoker }` → create `{ input, invoker, now }` / update `{ input, oldValue, invoker, now }` (`data` removed, `oldValue` added for update only) + - Type-level hooks: per-field mapping (`Hooks`) → single `{ create, update }` object (`TypeHook`) returning partial field overrides + - Type-level create hooks no longer receive `oldRecord`; update hooks receive non-nullable `oldRecord` + - Field-level validators: return type changed from `boolean` to `string | void` (return error message or void to pass); `[fn, message]` tuple form removed + - Type-level validators: `Validators` per-field record → `TypeValidateFn` single function with `issues(field, message)` callback + - Add `.default(value)` on fields to set a create-time default (makes required fields optional in create input) + - Remove exported types: `Hooks`, `Validators`, `ValidateConfig` + +### Patch Changes + +- [#1763](https://github.com/tailor-platform/sdk/pull/1763) [`2abbe40`](https://github.com/tailor-platform/sdk/commit/2abbe409ed77eb5e1c50d4aa0b65fbf26843fdb1) Thanks [@dqn](https://github.com/dqn)! - Reduce duplicated schema-derived type declarations. + +- [#1764](https://github.com/tailor-platform/sdk/pull/1764) [`7895ed6`](https://github.com/tailor-platform/sdk/commit/7895ed621dc87ec0307ddb511307a0daac637556) Thanks [@k1LoW](https://github.com/k1LoW)! - Correct the execution policy matching semantics in the workflow docs: overlapping policies now stack (every matching cap is enforced independently and the tightest blocks), not longest-prefix-wins. + +- [#1749](https://github.com/tailor-platform/sdk/pull/1749) [`f0e38ac`](https://github.com/tailor-platform/sdk/commit/f0e38ac5765e1d52ff8431262b387a681d99c82a) Thanks [@toiroakr](https://github.com/toiroakr)! - Upgrade politty to 0.11.2 and simplify the `skills` command wiring to use its new `globalArgs`/`commandMap`/`unknownKeys` customization options instead of hand-rolled schema rewriting. + +## 2.0.0-next.4 + +### Major Changes + +- [#1693](https://github.com/tailor-platform/sdk/pull/1693) [`4751214`](https://github.com/tailor-platform/sdk/commit/4751214c0923e094a844f9ce322279a47e871075) Thanks [@dqn](https://github.com/dqn)! - Rename the TailorDB schema builder from `db.type()` to `db.table()`. + + Update TailorDB definitions: + + ```diff + import { db } from "@tailor-platform/sdk"; + + -export const user = db.type("User", { + +export const user = db.table("User", { + name: db.string(), + }); + ``` + +- [#1704](https://github.com/tailor-platform/sdk/pull/1704) [`9c81d9c`](https://github.com/tailor-platform/sdk/commit/9c81d9c18b1d29b3e9307ea17fe54c8ce55f4dda) Thanks [@dqn](https://github.com/dqn)! - Remove flat value and default exports from `@tailor-platform/sdk/runtime/*` subpath modules. Import each subpath through its self-named namespace export instead, for example `import { iconv } from "@tailor-platform/sdk/runtime/iconv"`. + + The aggregate `@tailor-platform/sdk/runtime` entry remains named-only, and its deprecated `file.deleteFile` alias is removed in favor of `file.delete`. The v2 codemod rewrites straightforward namespace-star subpath imports, flat named value imports, and aggregate `file.deleteFile` calls to the new namespace-object style. + + `TailorContextAPI` and `TailorWorkflowAPI` now describe the SDK wrapper objects. Code that types the platform-provided `globalThis.tailor.context` or `globalThis.tailor.workflow` objects directly must use `PlatformContextAPI` or `PlatformWorkflowAPI` instead. + +### Minor Changes + +- [#1699](https://github.com/tailor-platform/sdk/pull/1699) [`f6a8d07`](https://github.com/tailor-platform/sdk/commit/f6a8d0779f94c2de5502dfdc68348e4a41ceee47) Thanks [@dqn](https://github.com/dqn)! - Allow `setup coordinate --action` values to group multiple generated actions into one multi-config deploy by separating action names with commas. + +### Patch Changes + +- [#1702](https://github.com/tailor-platform/sdk/pull/1702) [`03143f5`](https://github.com/tailor-platform/sdk/commit/03143f5213d9fa9d3c7697de78f2376994716679) Thanks [@dqn](https://github.com/dqn)! - Clarify the setup delete warning for coordinator action references so grouped `--action` values tell users to remove the action name from the relevant value. + +- [#1691](https://github.com/tailor-platform/sdk/pull/1691) [`2f3dbab`](https://github.com/tailor-platform/sdk/commit/2f3dbab7ed3cf40fa174a82053d70c23c356204d) Thanks [@dqn](https://github.com/dqn)! - Clarify when profile machine user override errors are caused by `TAILOR_PLATFORM_MACHINE_USER_NAME`. + +- [#1700](https://github.com/tailor-platform/sdk/pull/1700) [`0063115`](https://github.com/tailor-platform/sdk/commit/0063115f567ba7e73c0b679a392d5983869e8ac4) Thanks [@toiroakr](https://github.com/toiroakr)! - Fix `tailor` CLI failing with `ERR_MODULE_NOT_FOUND` when resolving extensionless relative imports of files whose basename contains a dot (e.g. `./permissions.generated`). + +- [#1705](https://github.com/tailor-platform/sdk/pull/1705) [`958d571`](https://github.com/tailor-platform/sdk/commit/958d571555a5c66e2e3b9beb3d2247f63cbb8f2c) Thanks [@toiroakr](https://github.com/toiroakr)! - Fix `tailor` CLI failing with `ERR_MODULE_NOT_FOUND` when resolving `tsconfig.json` path aliases (`compilerOptions.paths`) in projects that omit `baseUrl`, which is the standard style since TypeScript 5.0. Also fix path alias resolution to match TypeScript's `extends` behavior: a child config's `baseUrl` is now correctly inherited from an extended config, and a child config's own `paths` now replaces (rather than merges with) inherited `paths`. + +- [#1703](https://github.com/tailor-platform/sdk/pull/1703) [`4681778`](https://github.com/tailor-platform/sdk/commit/46817786354665cb97d0de63b78fa83e64096e03) Thanks [@toiroakr](https://github.com/toiroakr)! - Fix generated Kysely types for `db.date()` fields to use `Timestamp` instead of `string`, matching `db.datetime()` and allowing `insertInto`/`updateTable` calls to accept a `Date` value. + +## 2.0.0-next.3 + +### Major Changes + +- [#1559](https://github.com/tailor-platform/sdk/pull/1559) [`ff8ef1c`](https://github.com/tailor-platform/sdk/commit/ff8ef1c1323daf81812c182e146fd53da20e676e) Thanks [@dqn](https://github.com/dqn)! - Rename auth attribute module augmentation from `AttributeMap` to `Attributes`. + +- [#1529](https://github.com/tailor-platform/sdk/pull/1529) [`9ecb380`](https://github.com/tailor-platform/sdk/commit/9ecb380acdc1b37578c23c628ab46958663b4001) Thanks [@dqn](https://github.com/dqn)! - Reject unknown keys in SDK parser schemas instead of silently dropping them from application definitions. + +- [#1686](https://github.com/tailor-platform/sdk/pull/1686) [`aecaf8c`](https://github.com/tailor-platform/sdk/commit/aecaf8c1bb7813a32e998ea7d034684541cb1c85) Thanks [@dqn](https://github.com/dqn)! - Replace `tailor skills install` with project-local `tailor skills add`, `list`, `remove`, and `sync` commands for bundled Tailor SDK agent skills. + +- [#1622](https://github.com/tailor-platform/sdk/pull/1622) [`0fe8bad`](https://github.com/tailor-platform/sdk/commit/0fe8bad9afbb7702bc067ac9635b77c0438497a6) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated `auth.getConnectionToken()` helper from values returned by `defineAuth()`. Use `authconnection.getConnectionToken(...)` from `@tailor-platform/sdk/runtime` in resolvers, executors, and workflows instead. The v2 codemod rewrites direct `auth.getConnectionToken(...)` calls when the `auth` binding is imported from `tailor.config`. + +- [#1620](https://github.com/tailor-platform/sdk/pull/1620) [`1d71a52`](https://github.com/tailor-platform/sdk/commit/1d71a528ac57dd6fc0de2ab9b898b538608ac13e) Thanks [@dqn](https://github.com/dqn)! - Stop importing credentials and profiles from legacy `~/.tailorctl/config` when the platform config is missing. New CLI configs now start empty in the current platform config format. + +- [#1536](https://github.com/tailor-platform/sdk/pull/1536) [`84d9aba`](https://github.com/tailor-platform/sdk/commit/84d9aba843f14e8a7a43f0baff92dfc8afdf2821) Thanks [@toiroakr](https://github.com/toiroakr)! - Minimum Node.js version raised to 22.15.0; TypeScript loading switched from tsx to amaro + + Removes `tsx` (which pulled in esbuild's native binaries, ~10.5 MB) from + `dependencies` and replaces it with `amaro` (~3.8 MB, zero transitive deps). + + A small `ts-hook.mjs` provides the Node.js module hook with both a resolver + and a load hook (`amaro` for full TypeScript support including enums). + The resolver handles `.ts` extension fallback, directory barrel imports + (`./models` → `./models/index.ts`), and tsconfig `paths` aliases (reads + `tsconfig.json` following `extends` chains). + Dev-only scripts now use `node --experimental-strip-types` instead. + + Raises the minimum Node.js version to 22.15.0 (from >=22) to use + `module.registerHooks()`, which allows synchronous hook registration directly + in the main thread without a worker thread. + +- [#1557](https://github.com/tailor-platform/sdk/pull/1557) [`7ff575f`](https://github.com/tailor-platform/sdk/commit/7ff575fdfa15c00b5fc6282b28c0cb50bfdf927b) Thanks [@toiroakr](https://github.com/toiroakr)! - Rename the CLI binary from `tailor-sdk` to `tailor`. + + The output directory default changes from `.tailor-sdk` to `.tailor`, and the GitHub Actions lock file path changes from `.github/tailor-sdk.lock` to `.github/tailor.lock`. + + Run the `v2/rename-bin` codemod to migrate `tailor-sdk` invocations in package.json scripts, shell scripts, CI workflows, and documentation: + + ```sh + npx @tailor-platform/sdk-codemod --from 1.x --to 2.0.0 + ``` + +- [#1563](https://github.com/tailor-platform/sdk/pull/1563) [`501e8bf`](https://github.com/tailor-platform/sdk/commit/501e8bfdd2bca7201a1c9b036bf72087476da416) Thanks [@dqn](https://github.com/dqn)! - Standardize SDK-owned environment variables on the `TAILOR_*` namespace. + + Replace the removed SDK-specific environment variables with their new names: `TAILOR_CONFIG_PATH`, `TAILOR_DTS_PATH`, `TAILOR_CI_ALLOW_ID_INJECTION`, `TAILOR_DEPLOY_BUILD_ONLY`, `TAILOR_BUILD_OUTPUT_DIR`, `TAILOR_SKILLS_SOURCE`, `TAILOR_TEMPLATE_SDK_VERSION`, `TAILOR_PLATFORM_URL`, `TAILOR_PLATFORM_OAUTH2_CLIENT_ID`, `TAILOR_INLINE_SOURCEMAP`, `TAILOR_QUERY_NEWLINE_ON_ENTER`, and `TAILOR_APP_LOG_LEVEL`. The deprecated `TAILOR_TOKEN` fallback is removed; use `TAILOR_PLATFORM_TOKEN`. The v2 codemod rewrites unambiguous removed SDK environment variable names and flags generic names such as `LOG_LEVEL` and `PLATFORM_URL` for manual review. + +- [#1684](https://github.com/tailor-platform/sdk/pull/1684) [`de3ef5e`](https://github.com/tailor-platform/sdk/commit/de3ef5e7421a998624154df5e90da62e17664524) Thanks [@dqn](https://github.com/dqn)! - Restore Tailor field outputs for UUID, date, datetime, time, and decimal fields to plain string-compatible types and remove the strict scalar string migration guidance. + +- [#1556](https://github.com/tailor-platform/sdk/pull/1556) [`645949e`](https://github.com/tailor-platform/sdk/commit/645949ed64bda8b82fc44c0db54928698b12a2eb) Thanks [@toiroakr](https://github.com/toiroakr)! - Rename `defineWaitPoint` and `defineWaitPoints` to `createWaitPoint` and `createWaitPoints`. + + These functions create runtime instances with `.wait()` and `.resolve()` methods that call the platform API at runtime, so the `create*` prefix is more accurate. Update any usages: + + ```diff + -import { defineWaitPoint, defineWaitPoints } from "@tailor-platform/sdk"; + +import { createWaitPoint, createWaitPoints } from "@tailor-platform/sdk"; + + -export const approval = defineWaitPoint("approval"); + +export const approval = createWaitPoint("approval"); + + -export const waitPoints = defineWaitPoints((define) => ({ ... })); + +export const waitPoints = createWaitPoints((define) => ({ ... })); + ``` + +### Minor Changes + +- [#1501](https://github.com/tailor-platform/sdk/pull/1501) [`1e34d7a`](https://github.com/tailor-platform/sdk/commit/1e34d7a07acb5792f8e41b92b90cc339bf8cc73a) Thanks [@toiroakr](https://github.com/toiroakr)! - Add CLI plugin support (beta). Running `tailor ` for an unknown subcommand now executes an external `tailor-` executable found on your PATH or in `node_modules/.bin` (project-local takes precedence), forwarding all following arguments. This also works for unknown subcommands nested under a known command — e.g. `tailor tailordb erd` dispatches to `tailor-tailordb-erd`. Builtins always take precedence, matching stops at the first unknown segment, and a command that takes its own arguments is never replaced by a plugin. The plugin receives the current Tailor Platform context via environment variables (`TAILOR_PLATFORM_TOKEN`, `TAILOR_PLATFORM_URL`, `TAILOR_PLATFORM_OAUTH2_CLIENT_ID`, `TAILOR_PLATFORM_WORKSPACE_ID`, `TAILOR_PLATFORM_USER`, `TAILOR_CONFIG_PATH`, `TAILOR_VERSION`, `TAILOR_BIN`); token, workspace, and user are best-effort, so auth-free plugins still run when you are not logged in. + + Also adds: + + - `tailor auth token` — print a valid access token (refreshing it if expired) for use by plugins and scripts. + - `tailor plugin list` — list discovered plugins and their executable paths. + +### Patch Changes + +- [`ee35251`](https://github.com/tailor-platform/sdk/commit/ee35251e9211b35ce68f2ae5376c630f97662ddb) Thanks [@toiroakr](https://github.com/toiroakr)! - Remove the `openDownloadStream` method from `mockFile()` (`@tailor-platform/sdk/vitest`), matching the runtime file API, which no longer exposes it. Use `downloadStream` instead. + +- [#1629](https://github.com/tailor-platform/sdk/pull/1629) [`a0bc8e7`](https://github.com/tailor-platform/sdk/commit/a0bc8e7fe66147b5492bba358d7d2ec9b47c09be) Thanks [@dqn](https://github.com/dqn)! - Validate auth user profile TailorDB types with the strict TailorDB parser schema. + +## 2.0.0-next.2 +### Major Changes + + + +- [#1476](https://github.com/tailor-platform/sdk/pull/1476) [`fa83075`](https://github.com/tailor-platform/sdk/commit/fa83075f5e0e91085c0ef0cb44b7058a28a79ec3) Thanks [@toiroakr](https://github.com/toiroakr)! - `executeScript` now takes its `arg` as a JSON-serializable value instead of a pre-serialized JSON string. Pass the value directly (e.g. `arg: { a: 1 }`) instead of `arg: JSON.stringify({ a: 1 })`. + + Add the `v2/execute-script-arg` codemod, which unwraps `JSON.stringify(...)` passed as the `executeScript` `arg` option. Indirect forms (a stringified value held in a variable, etc.) cannot be rewritten automatically and are surfaced as an LLM-assisted review task with a migration prompt. + + +- [#1509](https://github.com/tailor-platform/sdk/pull/1509) [`7cadaa7`](https://github.com/tailor-platform/sdk/commit/7cadaa7c4987b81130ca80ba80bc5d5b26276394) Thanks [@dqn](https://github.com/dqn)! - Rename resolver, executor, workflow trigger, and typed workflow start machine-user options from `authInvoker` to `invoker`. + + Update create-sdk templates and the v2 auth invoker codemod to generate the new `invoker` option. + + +- [#1492](https://github.com/tailor-platform/sdk/pull/1492) [`41774d1`](https://github.com/tailor-platform/sdk/commit/41774d175d6e42ecce9fd0be458e1fea199fe78a) Thanks [@dqn](https://github.com/dqn)! - Store CLI login tokens in the OS keyring by default when available. If the keyring is unavailable, tokens are stored in the platform config file. + + + +- [#1484](https://github.com/tailor-platform/sdk/pull/1484) [`a376dc8`](https://github.com/tailor-platform/sdk/commit/a376dc8cd053d20744c90104e8b44ed2729ffe8c) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated `openDownloadStream` file streaming API. Use `downloadStream` for streamed file downloads. + + The generated file utilities now emit `downloadFileStream`, which calls `downloadStream` and returns `FileDownloadStreamResponse`, instead of the removed `openFileDownloadStream` helper. + + +- [#1510](https://github.com/tailor-platform/sdk/pull/1510) [`41809c7`](https://github.com/tailor-platform/sdk/commit/41809c75ca0f52d0872e55be095e0b73d026c622) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated workflow test env fallback. `WORKFLOW_TEST_ENV_KEY` is no longer exported, and `TAILOR_TEST_WORKFLOW_ENV` is no longer read when running workflows locally. Use `mockWorkflow().setEnv(...)` or pass `{ env }` to `runWorkflowLocally(...)` instead. + + + +- [#1439](https://github.com/tailor-platform/sdk/pull/1439) [`c5b10d2`](https://github.com/tailor-platform/sdk/commit/c5b10d2841ded08927285bce538c05220cde5e4c) Thanks [@dqn](https://github.com/dqn)! - Unify function principal context around `TailorPrincipal`. + + Resolver contexts now use `caller` and `invoker` as `TailorPrincipal | null`, workflow and executor invokers also use `TailorPrincipal | null`, and event executor `actor` uses `TailorPrincipal | null` with `id`/`type` fields. The legacy `TailorUser`, `TailorInvoker`, `TailorActor`, `TailorActorType`, and `unauthenticatedTailorUser` exports are removed. + + +- [#1498](https://github.com/tailor-platform/sdk/pull/1498) [`83145db`](https://github.com/tailor-platform/sdk/commit/83145db9a0d243aa68c1b641c2b6026771a62188) Thanks [@dqn](https://github.com/dqn)! - Set `db.fields.timestamps()` `updatedAt` when records are created and make the generated field non-null. `createdAt` keeps its existing create-time behavior, while `updatedAt` keeps its update-time behavior and now also gets a create hook that preserves provided values and falls back to the current time. + + Update create-sdk templates so scaffolded projects use the new non-null `updatedAt` Kysely types and seed schemas. + + Existing TailorDB schemas that already use this helper will change `updatedAt` from optional to required. Backfill existing records that have `updatedAt: null` before applying the schema change. + +### Patch Changes + + + +- [#1495](https://github.com/tailor-platform/sdk/pull/1495) [`6234022`](https://github.com/tailor-platform/sdk/commit/6234022d7dc03813b8dade831b86f63a5f7a20e6) Thanks [@toiroakr](https://github.com/toiroakr)! - Generate the v2 migration guide (`packages/sdk/docs/migration/v2.md`) from the codemod registry, which is the single source of truth. Each entry renders its name, automation level (Automatic / Partially automatic / Manual), description, optional before/after `examples`, and — for changes the codemods cannot fully migrate on their own — the LLM/manual migration prompt. Run `pnpm codemod:docs:update` to regenerate and `pnpm codemod:docs:check` (wired into `pnpm check`) to verify it is in sync. + + `scriptPath` is now optional, so the registry can also describe codemod-less ("manual") migrations that ship only guidance (`examples` / `prompt` / `suspiciousPatterns`) with no automatic transform. A manual entry with a `prompt` but no scoping pattern is surfaced as a project-wide `llmReviews` entry at runtime. + + Add a `sdk-codemod list` command that prints every registered rule (id, name, kind, version range). + + +- [#1516](https://github.com/tailor-platform/sdk/pull/1516) [`ec752bd`](https://github.com/tailor-platform/sdk/commit/ec752bd38f7dec817f7e3b4d2d5468e7320050e0) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency undici to v8.5.0 [security] + + + +- [#1525](https://github.com/tailor-platform/sdk/pull/1525) [`425a19d`](https://github.com/tailor-platform/sdk/commit/425a19dd58da6e373b739d3b3e838c2ff3d1736a) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency semver to v7.8.5 + +## 2.0.0-next.1 +### Major Changes + + + +- [#1442](https://github.com/tailor-platform/sdk/pull/1442) [`07cc256`](https://github.com/tailor-platform/sdk/commit/07cc256b9f1d695694d67438e1b0cb6df096ece8) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated `auth.invoker("")` helper. Pass machine user names directly as `authInvoker` strings in resolver, executor, and workflow APIs. + + + +- [#1440](https://github.com/tailor-platform/sdk/pull/1440) [`f0895f4`](https://github.com/tailor-platform/sdk/commit/f0895f4231578e54229004d3aa5bac6bd24361e3) Thanks [@dqn](https://github.com/dqn)! - Remove `defineGenerators()` and legacy `generators` config support. Use `definePlugins()` with the built-in plugin packages for code generation. + + + +- [#1441](https://github.com/tailor-platform/sdk/pull/1441) [`7604ad5`](https://github.com/tailor-platform/sdk/commit/7604ad5fdf27b791e8f1db880faed156cd6554d5) Thanks [@dqn](https://github.com/dqn)! - Remove support for wrapping `tailor-sdk function test-run --arg` resolver input in an `input` object. Pass resolver input fields directly as the `--arg` JSON value. + + + +- [#1460](https://github.com/tailor-platform/sdk/pull/1460) [`f49c6d1`](https://github.com/tailor-platform/sdk/commit/f49c6d1b5a856969cb4e04ae7d3a87ed34aa020f) Thanks [@dqn](https://github.com/dqn)! - Remove the v1 runtime globals compatibility layer. Importing from `@tailor-platform/sdk` no longer activates the ambient `tailor.*` / `tailordb.*` declarations; opt into globals with `@tailor-platform/sdk/runtime/globals` or use the typed wrappers from `@tailor-platform/sdk/runtime`. + + The capital-cased `Tailordb.*` namespace is removed. If your project still references `Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, or `typeof Tailordb.Client`, migrate before upgrading: run `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` to rewrite them to lowercase `tailordb.*`, then add `import "@tailor-platform/sdk/runtime/globals"` so the rewritten references resolve. + + +- [#1459](https://github.com/tailor-platform/sdk/pull/1459) [`2c3d7ad`](https://github.com/tailor-platform/sdk/commit/2c3d7add213b171df2959b8a14e8dc2e3c3a7ec7) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated `tailor-sdk-skills` binary shim. Use `tailor-sdk skills install` to install the bundled Tailor SDK agent skill. + + + +- [#1457](https://github.com/tailor-platform/sdk/pull/1457) [`84325f8`](https://github.com/tailor-platform/sdk/commit/84325f8602a5631b7c323c997b1425235509920e) Thanks [@dqn](https://github.com/dqn)! - Remove deprecated CLI aliases for the v2 command surface. Use `tailor-sdk deploy` instead of `tailor-sdk apply`, `tailor-sdk crashreport` instead of `tailor-sdk crash-report`, and the hyphenated `--machine-user` option instead of the hidden `--machineuser` alias. + + Fix the v2 CLI rename codemod to migrate the hidden `--machineuser` option to `--machine-user`. + + +- [#1462](https://github.com/tailor-platform/sdk/pull/1462) [`77e7f45`](https://github.com/tailor-platform/sdk/commit/77e7f4512ddd141a8858ab37a3c48d8ad7e16543) Thanks [@dqn](https://github.com/dqn)! - Require `FunctionExecution.contentHash` for `function logs` stack trace source mapping. Executions without a content hash now show raw stack traces instead of mapping against the current function bundle. + + + +- [#1458](https://github.com/tailor-platform/sdk/pull/1458) [`ad04913`](https://github.com/tailor-platform/sdk/commit/ad049131d61dfc9f96bff8ffe7c5e5e261523b14) Thanks [@dqn](https://github.com/dqn)! - Store CLI human users by stable subject ID in the platform config instead of email, while preserving email for display and migrating legacy email-keyed entries on login or token refresh. + + + +- [#1438](https://github.com/tailor-platform/sdk/pull/1438) [`2c552cd`](https://github.com/tailor-platform/sdk/commit/2c552cd716f9abbe90ec4b22e51d0cde155c2bf9) Thanks [@dqn](https://github.com/dqn)! - Align workflow job `.trigger()` with the platform runtime. Job triggers now require a mocked workflow runtime in tests instead of running job bodies locally, and `trigger()` returns the job result directly instead of a Promise wrapper. Use `mockWorkflow()` to mock trigger results in tests, or `runWorkflowLocally()` for full-chain local workflow tests. + + +### Patch Changes + + + +- [#1425](https://github.com/tailor-platform/sdk/pull/1425) [`644dca8`](https://github.com/tailor-platform/sdk/commit/644dca8ee631ff18550646d3d82bad76fba6bc33) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency graphql to v16.14.2 + + + +- [#1429](https://github.com/tailor-platform/sdk/pull/1429) [`b933f29`](https://github.com/tailor-platform/sdk/commit/b933f291b99efb3668077cd7870abe979dc3b10b) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update rolldown to v1.1.1 + + + +- [#1433](https://github.com/tailor-platform/sdk/pull/1433) [`4d07f2f`](https://github.com/tailor-platform/sdk/commit/4d07f2fd814bda5886c8f0c9546f21128dcce74b) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency es-toolkit to v1.47.1 + + + +- [#1448](https://github.com/tailor-platform/sdk/pull/1448) [`4ce01dd`](https://github.com/tailor-platform/sdk/commit/4ce01dd851dae7754103a918ba89afe073f942c6) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update @connectrpc to v2.1.2 + + + +- [#1390](https://github.com/tailor-platform/sdk/pull/1390) [`388f3d6`](https://github.com/tailor-platform/sdk/commit/388f3d69b722589cd5cca7bbbc3667a849ecaa3b) Thanks [@toiroakr](https://github.com/toiroakr)! - Guarantee that importing the SDK never loads zod in user projects — neither zod runtime code in bundled functions nor zod type computation in tsc. Internal type definitions are reorganized into per-layer pure type modules, and new CI checks verify every user-facing entry point stays zod-free at both the type and runtime level and that the internal module graph has no import cycles. + +## 2.0.0-next.0 +### Major Changes + + + +- [#1451](https://github.com/tailor-platform/sdk/pull/1451) [`3e6d582`](https://github.com/tailor-platform/sdk/commit/3e6d582a37d83a42302339cc4aea1d3dd11e8a81) Thanks [@tailor-platform-pr-trigger](https://github.com/apps/tailor-platform-pr-trigger)! - Start the v2 release line. v2 introduces breaking changes to the SDK API and CLI; run `tailor-sdk upgrade` to apply the bundled codemods when migrating. Prereleases are published to the `next` dist-tag — install with `@tailor-platform/sdk@next`. + + +### Patch Changes + + + +- [#1449](https://github.com/tailor-platform/sdk/pull/1449) [`016aff6`](https://github.com/tailor-platform/sdk/commit/016aff6aab31c334c57a5e5244453f2dd559c008) Thanks [@k1LoW](https://github.com/k1LoW)! - Document the `userAuthPolicy`, `gqlOperations`, and `lang` options of `defineIdp()` in the IdP service guide, including the password policy fields, allowed email domains, Google/Microsoft social login, the read-only `"query"` shortcut, and the cross-field validation constraints. + + + +- [#1450](https://github.com/tailor-platform/sdk/pull/1450) [`162ba62`](https://github.com/tailor-platform/sdk/commit/162ba629e0d511593718f289b93788d5d56778da) Thanks [@toiroakr](https://github.com/toiroakr)! - Update OpenTelemetry runtime dependencies to 2.8.0 to resolve a moderate security advisory (GHSA-8988-4f7v-96qf) in `@opentelemetry/core` + + + +- [#1432](https://github.com/tailor-platform/sdk/pull/1432) [`3a854a3`](https://github.com/tailor-platform/sdk/commit/3a854a3a10b938ce3cf6fe7527de4ab56ecf48d5) Thanks [@toiroakr](https://github.com/toiroakr)! - Roll back a migration's pre-migration schema changes when its data migration (`migrate.ts`) fails during `apply`. A failed migration now leaves the workspace at its prior checkpoint and prior schema instead of half-applied, so subsequent deploys are no longer blocked by opaque "Remote schema drift detected" errors. + + + +- [#1422](https://github.com/tailor-platform/sdk/pull/1422) [`f3f8427`](https://github.com/tailor-platform/sdk/commit/f3f84277fe1942601d0fcbb8a64c2c26823b5624) Thanks [@dqn](https://github.com/dqn)! - Internal cleanup of proto field optionality handling. No behavior change. + + + +- [#1421](https://github.com/tailor-platform/sdk/pull/1421) [`b933f47`](https://github.com/tailor-platform/sdk/commit/b933f474d65f8dfed56f3991aae3a52589368b10) Thanks [@dqn](https://github.com/dqn)! - Corrupted or hand-edited TailorDB migration snapshot/diff files now fail with a clear validation error when loaded, instead of causing undefined behavior later. + ## 1.78.0 ### Minor Changes @@ -22,12 +381,8 @@ ### Patch Changes -- [#1763](https://github.com/tailor-platform/sdk/pull/1763) [`2abbe40`](https://github.com/tailor-platform/sdk/commit/2abbe409ed77eb5e1c50d4aa0b65fbf26843fdb1) Thanks [@dqn](https://github.com/dqn)! - Reduce duplicated schema-derived type declarations. - - [#1770](https://github.com/tailor-platform/sdk/pull/1770) [`9ca5eaa`](https://github.com/tailor-platform/sdk/commit/9ca5eaa4c92b699aa49c79913975cdcfd702214d) Thanks [@toiroakr](https://github.com/toiroakr)! - Document the type-level `gqlOperations` feature (`.features({ gqlOperations })`, including the `"query"` read-only alias) and the namespace-level `db.tailordb.gqlOperations` default in `tailor.config.ts`, clarifying that the namespace default also updates types that already exist on the platform. -- [#1764](https://github.com/tailor-platform/sdk/pull/1764) [`7895ed6`](https://github.com/tailor-platform/sdk/commit/7895ed621dc87ec0307ddb511307a0daac637556) Thanks [@k1LoW](https://github.com/k1LoW)! - Correct the execution policy matching semantics in the workflow docs: overlapping policies now stack (every matching cap is enforced independently and the tightest blocks), not longest-prefix-wins. - ## 1.77.0 ### Minor Changes @@ -92,16 +447,8 @@ ## 1.76.0 -### Minor Changes - -- [#1699](https://github.com/tailor-platform/sdk/pull/1699) [`f6a8d07`](https://github.com/tailor-platform/sdk/commit/f6a8d0779f94c2de5502dfdc68348e4a41ceee47) Thanks [@dqn](https://github.com/dqn)! - Allow `setup coordinate --action` values to group multiple generated actions into one multi-config deploy by separating action names with commas. - ### Patch Changes -- [#1702](https://github.com/tailor-platform/sdk/pull/1702) [`03143f5`](https://github.com/tailor-platform/sdk/commit/03143f5213d9fa9d3c7697de78f2376994716679) Thanks [@dqn](https://github.com/dqn)! - Clarify the setup delete warning for coordinator action references so grouped `--action` values tell users to remove the action name from the relevant value. - -- [#1691](https://github.com/tailor-platform/sdk/pull/1691) [`2f3dbab`](https://github.com/tailor-platform/sdk/commit/2f3dbab7ed3cf40fa174a82053d70c23c356204d) Thanks [@dqn](https://github.com/dqn)! - Clarify when profile machine user override errors are caused by `TAILOR_PLATFORM_MACHINE_USER_NAME`. - - [#1701](https://github.com/tailor-platform/sdk/pull/1701) [`e8bcbdf`](https://github.com/tailor-platform/sdk/commit/e8bcbdf92f90f20cc292a4f6b998475e4aa35c8e) Thanks [@dqn](https://github.com/dqn)! - Show the `profile update --user` recovery command when `tailor-sdk login --profile` authenticates a different user than the profile references. - [#1698](https://github.com/tailor-platform/sdk/pull/1698) [`dd25b69`](https://github.com/tailor-platform/sdk/commit/dd25b69ba829e7d446731b4cbf2f6753d2abde95) Thanks [@dqn](https://github.com/dqn)! - Reject duplicate TailorDB type-level builder calls at type-check time and runtime instead of silently overwriting earlier settings. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 71e4bc1a7..7bdacd5ca 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -50,19 +50,19 @@ For more details, see the [Quickstart Guide](./docs/quickstart.md). ## Agent Skill -Install the `tailor-sdk` skill from the locally installed SDK package: +Install the `tailor` skill from the locally installed SDK package: ```bash -npx tailor-sdk skills install +npx tailor skills add -# Example: install to Codex in non-interactive mode -npx tailor-sdk skills install -a codex -y +# Or refresh all skills owned by this CLI +npx tailor skills sync ``` -This uses the `skills` CLI under the hood, sourcing the skill from -`node_modules/@tailor-platform/sdk/agent-skills` so the skill version always matches -the installed SDK version. Files are copied (not symlinked) so they survive -`pnpm install` wiping `node_modules`. +This sources the skill from `node_modules/@tailor-platform/sdk/agent-skills` +so the skill version always matches the installed SDK version. Files are copied +into the current project under `.agents/skills` and `.claude/skills`, so rerun +`skills sync` after upgrading the SDK. ## Learn More @@ -96,5 +96,5 @@ See [Create Tailor Platform SDK](https://github.com/tailor-platform/sdk/tree/mai ## Requirements -- Node.js 22 or later (or Bun) +- Node.js 22.15.0 or later (or Bun) - A Tailor Platform account ([request access](https://www.tailor.tech/demo)) diff --git a/packages/sdk/agent-skills/tailor-sdk/SKILL.md b/packages/sdk/agent-skills/tailor/SKILL.md similarity index 95% rename from packages/sdk/agent-skills/tailor-sdk/SKILL.md rename to packages/sdk/agent-skills/tailor/SKILL.md index 7d1dcdcf2..e1ca701de 100644 --- a/packages/sdk/agent-skills/tailor-sdk/SKILL.md +++ b/packages/sdk/agent-skills/tailor/SKILL.md @@ -1,6 +1,8 @@ --- -name: tailor-sdk +name: tailor description: Use this skill when working with @tailor-platform/sdk projects, including service configuration, CLI usage, and docs navigation. +metadata: + politty-cli: "@tailor-platform/sdk:tailor" --- # Tailor SDK Knowledge diff --git a/packages/sdk/docs/cli-reference.md b/packages/sdk/docs/cli-reference.md index f63ce9848..20f3945c8 100644 --- a/packages/sdk/docs/cli-reference.md +++ b/packages/sdk/docs/cli-reference.md @@ -1,11 +1,11 @@ -# tailor-sdk +# tailor Tailor Platform SDK - The SDK to work with Tailor Platform ## Usage ```bash -tailor-sdk [options] +tailor [options] ``` ## Global Options @@ -42,7 +42,7 @@ The following options are available for most commands: | ---------------- | ----- | -------------------------------------- | | `--workspace-id` | `-w` | Workspace ID (for deployment commands) | | `--profile` | `-p` | Workspace profile | -| `--config` | `-c` | Path to SDK config file | +| `--config` | `-c` | Path to Tailor config file | | `--yes` | `-y` | Skip confirmation prompts | ### Environment File Loading @@ -55,10 +55,10 @@ Both `--env-file` and `--env-file-if-exists` can be specified multiple times and ```bash # Load .env (required) and .env.local (optional, if exists) -tailor-sdk deploy --env-file .env --env-file-if-exists .env.local +tailor deploy --env-file .env --env-file-if-exists .env.local # Load multiple files -tailor-sdk deploy --env-file .env --env-file .env.production +tailor deploy --env-file .env --env-file .env.production ``` ## Environment Variables @@ -71,10 +71,9 @@ You can use environment variables to configure workspace and authentication: | `TAILOR_PLATFORM_ORGANIZATION_ID` | Organization ID for organization commands | | `TAILOR_PLATFORM_FOLDER_ID` | Folder ID for folder commands | | `TAILOR_PLATFORM_TOKEN` | Authentication token (alternative to `login`) | -| `TAILOR_TOKEN` | **Deprecated.** Use `TAILOR_PLATFORM_TOKEN` instead | | `TAILOR_PLATFORM_PROFILE` | Workspace profile name | -| `TAILOR_PLATFORM_SDK_CONFIG_PATH` | Path to SDK config file | -| `TAILOR_PLATFORM_SDK_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | +| `TAILOR_CONFIG_PATH` | Path to Tailor config file | +| `TAILOR_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | | `TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID` | Client ID for `login --machine-user` | | `TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET` | Client secret for `login --machine-user` | | `TAILOR_PLATFORM_MACHINE_USER_NAME` | Default machine user name for `query`, `workflow start`, `function test-run`, `machineuser token` | @@ -92,9 +91,8 @@ You can use environment variables to configure workspace and authentication: Token resolution follows this priority order: 1. `TAILOR_PLATFORM_TOKEN` environment variable -2. `TAILOR_TOKEN` environment variable (deprecated) -3. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` -4. Current user from platform config (`~/.config/tailor-platform/config.yaml`) +2. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` +3. Current user from platform config (`~/.config/tailor-platform/config.yaml`) Config-backed login tokens are scoped to the Platform API URL. Profiles with `--platform-url` use the token saved for that URL, so switching profiles can also switch between Platform API environments. @@ -106,6 +104,73 @@ Workspace ID resolution follows this priority order: 2. `TAILOR_PLATFORM_WORKSPACE_ID` environment variable 3. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` +## CLI Plugins + +> [!WARNING] +> CLI plugins are a **beta** feature. The dispatch behavior and the set of injected environment +> variables may change in a future release. + +You can extend the CLI with external plugins, similar to `gh` extensions. When you run a command that +is not a built-in, the CLI looks for an executable named `tailor-` and runs it, forwarding the +remaining arguments: + +```bash +# Runs the `tailor-hello` executable with: world --loud +tailor hello world --loud +``` + +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` +package provides the `tailordb erd` commands: + +```bash +# Runs `tailor-tailordb-erd` with: export +tailor tailordb erd export +``` + +Resolution rules: + +- **Built-ins always win.** A plugin is only used when no built-in command matches. +- **A command that takes its own arguments is never replaced.** Plugin dispatch applies only to command + _groups_ (commands that just route to subcommands). A command that performs its own action — including + one that accepts a positional argument — always runs itself, so a plugin can never shadow an argument value. +- **Lookup order:** the project's `node_modules/.bin` (nearest first, walking up from the current + directory), then your `PATH`. So a plugin installed as a project dev-dependency takes precedence over a + globally installed one. +- **Place global flags after the plugin command.** Only the arguments following the plugin name are + forwarded; a global flag placed before it (e.g. `tailor --json tailordb erd export`) is consumed by + the host CLI and does not reach the plugin. Write `tailor tailordb erd export --json` instead. + +Because resolution is based on `node_modules/.bin` and `PATH`, any package manager that populates +`node_modules/.bin` works for project-local plugins — npm, pnpm (its content-addressable store is +transparent here), Bun, and Yarn Classic. The exception is **Yarn Plug'n'Play**, which does not create a +`node_modules` directory: install such plugins globally so they resolve via `PATH`, or use Yarn's +`nodeLinker: node-modules` setting. + +Run `tailor plugin list` to see which plugins are discovered and where they resolve from. + +### Context passed to plugins + +Before running a plugin, the CLI injects the current Tailor Platform context as environment variables so +the plugin does not need to re-implement authentication or re-resolve the active workspace: + +| Variable | Description | +| ---------------------------------- | ------------------------------------------------------------------------ | +| `TAILOR_PLATFORM_TOKEN` | A valid access token (refreshed if needed). Omitted when not logged in. | +| `TAILOR_PLATFORM_URL` | The Tailor Platform endpoint in effect | +| `TAILOR_PLATFORM_OAUTH2_CLIENT_ID` | The OAuth2 client ID in effect, for plugins that run their own auth flow | +| `TAILOR_PLATFORM_WORKSPACE_ID` | The resolved workspace ID, when one can be determined | +| `TAILOR_PLATFORM_USER` | The active user (email when known), when logged in | +| `TAILOR_CONFIG_PATH` | Path to the resolved Tailor config file, when found | +| `TAILOR_VERSION` | The `tailor` version that invoked the plugin | +| `TAILOR_BIN` | Path to the `tailor` executable, for calling back into the CLI | + +The token, workspace ID, and user are best-effort: whatever the current context can resolve is injected, +and auth-free plugins still run when you are not logged in. A long-running plugin (or one started on its +own) can obtain a fresh token at any time with `tailor auth token`, which prints a valid access token to +stdout, refreshing it first if it has expired. + ## Commands ### [Application Commands](./cli/application.md) @@ -138,11 +203,6 @@ Commands for managing TailorDB tables, data, and schema migrations. | [tailordb migration set](./cli/tailordb.md#tailordb-migration-set) | Set migration checkpoint to a specific number. | | [tailordb migration status](./cli/tailordb.md#tailordb-migration-status) | Show the current migration status for TailorDB namespaces, including applied and pending migrations. | | [tailordb migration sync](./cli/tailordb.md#tailordb-migration-sync) | Sync remote TailorDB schema to a specific migration snapshot (recovery from --no-schema-check drift). | -| [tailordb erd](./cli/tailordb.md#tailordb-erd) | Generate TailorDB ERD viewer artifacts from local TailorDB schema. (beta) | -| [tailordb erd export](./cli/tailordb.md#tailordb-erd-export) | Export TailorDB ERD static viewer from local TailorDB schema. | -| [tailordb erd diff](./cli/tailordb.md#tailordb-erd-diff) | Render TailorDB ERD schema diff HTML from exported ERD viewers. | -| [tailordb erd serve](./cli/tailordb.md#tailordb-erd-serve) | Generate and serve TailorDB ERD locally with watch reload. (beta) | -| [tailordb erd deploy](./cli/tailordb.md#tailordb-erd-deploy) | Deploy ERD static website for TailorDB namespace(s). | ### [Query Commands](./cli/query.md) @@ -156,19 +216,21 @@ Run ad-hoc SQL/GraphQL queries or enter the interactive REPL. Commands for authentication and user management. -| Command | Description | -| ------------------------------------------------ | ----------------------------------------------------- | -| [login](./cli/user.md#login) | Login to Tailor Platform. | -| [logout](./cli/user.md#logout) | Logout from Tailor Platform. | -| [user](./cli/user.md#user) | Manage Tailor Platform users. | -| [user current](./cli/user.md#user-current) | Show current user. | -| [user list](./cli/user.md#user-list) | List all users. | -| [user pat](./cli/user.md#user-pat) | Manage personal access tokens. | -| [user pat create](./cli/user.md#user-pat-create) | Create a new personal access token. | -| [user pat delete](./cli/user.md#user-pat-delete) | Delete a personal access token. | -| [user pat list](./cli/user.md#user-pat-list) | List all personal access tokens. | -| [user pat update](./cli/user.md#user-pat-update) | Update a personal access token (delete and recreate). | -| [user switch](./cli/user.md#user-switch) | Set current user. | +| Command | Description | +| ------------------------------------------------ | ------------------------------------------------------------------------------------- | +| [login](./cli/user.md#login) | Login to Tailor Platform. | +| [logout](./cli/user.md#logout) | Logout from Tailor Platform. | +| [auth](./cli/user.md#auth) | Authentication helpers for scripts and plugins. | +| [auth token](./cli/user.md#auth-token) | Print a valid Tailor Platform access token to stdout, refreshing it first if expired. | +| [user](./cli/user.md#user) | Manage Tailor Platform users. | +| [user current](./cli/user.md#user-current) | Show current user. | +| [user list](./cli/user.md#user-list) | List all users. | +| [user pat](./cli/user.md#user-pat) | Manage personal access tokens. | +| [user pat create](./cli/user.md#user-pat-create) | Create a new personal access token. | +| [user pat delete](./cli/user.md#user-pat-delete) | Delete a personal access token. | +| [user pat list](./cli/user.md#user-pat-list) | List all personal access tokens. | +| [user pat update](./cli/user.md#user-pat-update) | Update a personal access token (delete and recreate). | +| [user switch](./cli/user.md#user-switch) | Set current user. | ### [Organization Commands](./cli/organization.md) @@ -324,7 +386,7 @@ Commands for setting up project infrastructure. | [setup branch](./cli/setup.md#setup-branch) | Generate a branch-target deploy workflow (push to branch triggers deploy). | | [setup check](./cli/setup.md#setup-check) | Audit generated workflows for drift against the current config/repo (read-only). | | [setup coordinate](./cli/setup.md#setup-coordinate) | Generate a coordinator workflow that orchestrates multiple --action-generated composite actions. | -| [setup delete](./cli/setup.md#setup-delete) | Delete managed workflow/action file(s) and their .github/tailor-sdk.lock entries. | +| [setup delete](./cli/setup.md#setup-delete) | Delete managed workflow/action file(s) and their .github/tailor.lock entries. | | [setup preview](./cli/setup.md#setup-preview) | Generate a preview workflow (PR open/sync triggers deploy to a per-PR workspace). | | [setup tag](./cli/setup.md#setup-tag) | Generate a tag-target deploy workflow (tag push triggers deploy). | @@ -338,12 +400,24 @@ Commands for upgrading SDK versions with automated code migration. ### [Skills Commands](./cli/skills.md) -Commands for installing Tailor SDK agent skills. +Commands for managing Tailor SDK agent skills. + +| Command | Description | +| ---------------------------------------------- | --------------------------------------------- | +| [skills](./cli/skills.md#skills) | Manage Tailor SDK agent skills. | +| [skills add](./cli/skills.md#skills-add) | Install Tailor SDK agent skills. | +| [skills list](./cli/skills.md#skills-list) | List Tailor SDK agent skills. | +| [skills remove](./cli/skills.md#skills-remove) | Remove installed Tailor SDK agent skills. | +| [skills sync](./cli/skills.md#skills-sync) | Remove and reinstall Tailor SDK agent skills. | + +### [Plugin Commands](./cli/plugin.md) + +Discover and inspect CLI plugins (external `tailor-` executables). -| Command | Description | -| ------------------------------------------------ | ------------------------------------------------------------------ | -| [skills](./cli/skills.md#skills) | Manage Tailor SDK agent skills. | -| [skills install](./cli/skills.md#skills-install) | Install the tailor-sdk agent skill from the installed SDK package. | +| Command | Description | +| ------------------------------------------ | ---------------------------------------------------------------------------------------- | +| [plugin](./cli/plugin.md#plugin) | Manage and inspect CLI plugins (beta). | +| [plugin list](./cli/plugin.md#plugin-list) | List discovered plugins (executables named `-` on PATH or node_modules/.bin). | ### [Completion](./cli/completion.md) diff --git a/packages/sdk/docs/cli-reference.template.md b/packages/sdk/docs/cli-reference.template.md index af7582f7a..410dfb83f 100644 --- a/packages/sdk/docs/cli-reference.template.md +++ b/packages/sdk/docs/cli-reference.template.md @@ -1,11 +1,11 @@ -# tailor-sdk +# tailor Tailor Platform SDK - The SDK to work with Tailor Platform ## Usage ```bash -tailor-sdk [options] +tailor [options] ``` ## Global Options @@ -35,7 +35,7 @@ The following options are available for most commands: | ---------------- | ----- | -------------------------------------- | | `--workspace-id` | `-w` | Workspace ID (for deployment commands) | | `--profile` | `-p` | Workspace profile | -| `--config` | `-c` | Path to SDK config file | +| `--config` | `-c` | Path to Tailor config file | | `--yes` | `-y` | Skip confirmation prompts | ### Environment File Loading @@ -48,10 +48,10 @@ Both `--env-file` and `--env-file-if-exists` can be specified multiple times and ```bash # Load .env (required) and .env.local (optional, if exists) -tailor-sdk deploy --env-file .env --env-file-if-exists .env.local +tailor deploy --env-file .env --env-file-if-exists .env.local # Load multiple files -tailor-sdk deploy --env-file .env --env-file .env.production +tailor deploy --env-file .env --env-file .env.production ``` ## Environment Variables @@ -64,10 +64,9 @@ You can use environment variables to configure workspace and authentication: | `TAILOR_PLATFORM_ORGANIZATION_ID` | Organization ID for organization commands | | `TAILOR_PLATFORM_FOLDER_ID` | Folder ID for folder commands | | `TAILOR_PLATFORM_TOKEN` | Authentication token (alternative to `login`) | -| `TAILOR_TOKEN` | **Deprecated.** Use `TAILOR_PLATFORM_TOKEN` instead | | `TAILOR_PLATFORM_PROFILE` | Workspace profile name | -| `TAILOR_PLATFORM_SDK_CONFIG_PATH` | Path to SDK config file | -| `TAILOR_PLATFORM_SDK_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | +| `TAILOR_CONFIG_PATH` | Path to Tailor config file | +| `TAILOR_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | | `TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID` | Client ID for `login --machine-user` | | `TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET` | Client secret for `login --machine-user` | | `TAILOR_PLATFORM_MACHINE_USER_NAME` | Default machine user name for `query`, `workflow start`, `function test-run`, `machineuser token` | @@ -85,9 +84,8 @@ You can use environment variables to configure workspace and authentication: Token resolution follows this priority order: 1. `TAILOR_PLATFORM_TOKEN` environment variable -2. `TAILOR_TOKEN` environment variable (deprecated) -3. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` -4. Current user from platform config (`~/.config/tailor-platform/config.yaml`) +2. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` +3. Current user from platform config (`~/.config/tailor-platform/config.yaml`) Config-backed login tokens are scoped to the Platform API URL. Profiles with `--platform-url` use the token saved for that URL, so switching profiles can also switch between Platform API environments. @@ -99,6 +97,73 @@ Workspace ID resolution follows this priority order: 2. `TAILOR_PLATFORM_WORKSPACE_ID` environment variable 3. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` +## CLI Plugins + +> [!WARNING] +> CLI plugins are a **beta** feature. The dispatch behavior and the set of injected environment +> variables may change in a future release. + +You can extend the CLI with external plugins, similar to `gh` extensions. When you run a command that +is not a built-in, the CLI looks for an executable named `tailor-` and runs it, forwarding the +remaining arguments: + +```bash +# Runs the `tailor-hello` executable with: world --loud +tailor hello world --loud +``` + +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` +package provides the `tailordb erd` commands: + +```bash +# Runs `tailor-tailordb-erd` with: export +tailor tailordb erd export +``` + +Resolution rules: + +- **Built-ins always win.** A plugin is only used when no built-in command matches. +- **A command that takes its own arguments is never replaced.** Plugin dispatch applies only to command + _groups_ (commands that just route to subcommands). A command that performs its own action — including + one that accepts a positional argument — always runs itself, so a plugin can never shadow an argument value. +- **Lookup order:** the project's `node_modules/.bin` (nearest first, walking up from the current + directory), then your `PATH`. So a plugin installed as a project dev-dependency takes precedence over a + globally installed one. +- **Place global flags after the plugin command.** Only the arguments following the plugin name are + forwarded; a global flag placed before it (e.g. `tailor --json tailordb erd export`) is consumed by + the host CLI and does not reach the plugin. Write `tailor tailordb erd export --json` instead. + +Because resolution is based on `node_modules/.bin` and `PATH`, any package manager that populates +`node_modules/.bin` works for project-local plugins — npm, pnpm (its content-addressable store is +transparent here), Bun, and Yarn Classic. The exception is **Yarn Plug'n'Play**, which does not create a +`node_modules` directory: install such plugins globally so they resolve via `PATH`, or use Yarn's +`nodeLinker: node-modules` setting. + +Run `tailor plugin list` to see which plugins are discovered and where they resolve from. + +### Context passed to plugins + +Before running a plugin, the CLI injects the current Tailor Platform context as environment variables so +the plugin does not need to re-implement authentication or re-resolve the active workspace: + +| Variable | Description | +| ---------------------------------- | ------------------------------------------------------------------------ | +| `TAILOR_PLATFORM_TOKEN` | A valid access token (refreshed if needed). Omitted when not logged in. | +| `TAILOR_PLATFORM_URL` | The Tailor Platform endpoint in effect | +| `TAILOR_PLATFORM_OAUTH2_CLIENT_ID` | The OAuth2 client ID in effect, for plugins that run their own auth flow | +| `TAILOR_PLATFORM_WORKSPACE_ID` | The resolved workspace ID, when one can be determined | +| `TAILOR_PLATFORM_USER` | The active user (email when known), when logged in | +| `TAILOR_CONFIG_PATH` | Path to the resolved Tailor config file, when found | +| `TAILOR_VERSION` | The `tailor` version that invoked the plugin | +| `TAILOR_BIN` | Path to the `tailor` executable, for calling back into the CLI | + +The token, workspace ID, and user are best-effort: whatever the current context can resolve is injected, +and auth-free plugins still run when you are not logged in. A long-running plugin (or one started on its +own) can obtain a fresh token at any time with `tailor auth token`, which prints a valid access token to +stdout, refreshing it first if it has expired. + ## Commands {{politty:index}} diff --git a/packages/sdk/docs/cli/application.md b/packages/sdk/docs/cli/application.md index d82a47ad5..bcfad4ca7 100644 --- a/packages/sdk/docs/cli/application.md +++ b/packages/sdk/docs/cli/application.md @@ -9,7 +9,7 @@ Initialize a new project using create-sdk. **Usage** ``` -tailor-sdk init [options] [name] +tailor init [options] [name] ``` **Arguments** @@ -33,15 +33,14 @@ Generate files using Tailor configuration. **Usage** ``` -tailor-sdk generate [options] +tailor generate [options] ``` **Options** -| Option | Alias | Description | Required | Default | -| ------------------- | ----- | ---------------------------------------------- | -------- | -------------------- | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | -| `--watch` | `-W` | Watch for type/resolver changes and regenerate | No | `false` | +| Option | Alias | Description | Required | Default | +| ------------------- | ----- | ----------------------- | -------- | -------------------- | +| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -49,12 +48,10 @@ See [Global Options](../cli-reference.md#global-options) for options available t Deploy your application by applying the Tailor configuration. -**Aliases:** `apply` - **Usage** ``` -tailor-sdk deploy [options] +tailor deploy [options] ``` **Options** @@ -128,7 +125,7 @@ On first run, `deploy` automatically injects a stable `id: ""` field into To deploy interdependent applications to the same workspace in one run, pass comma-separated config paths: ```bash -tailor-sdk deploy --config apps/buyer/tailor.config.ts,apps/supplier/tailor.config.ts +tailor deploy --config apps/buyer/tailor.config.ts,apps/supplier/tailor.config.ts ``` When multiple configs are provided, `deploy` creates or updates all configured services first, then updates the applications. This lets one application reference resources owned by another config with `external: true` during the same deploy. @@ -175,7 +172,7 @@ Plan: 5 to create, 3 to update, 1 to delete Use `--dry-run` to preview the plan without applying anything. In dry-run mode the plan is written to **stdout**, so it can be captured in CI without `2>&1`: ```bash -tailor-sdk deploy --dry-run > plan.txt +tailor deploy --dry-run > plan.txt ``` In apply mode, the plan is printed to stderr so it does not interfere with piped output. @@ -216,17 +213,17 @@ Remove all resources managed by the application from the workspace. **Usage** ``` -tailor-sdk remove [options] +tailor remove [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -237,16 +234,16 @@ Show information about the deployed application. **Usage** ``` -tailor-sdk show [options] +tailor show [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -257,16 +254,16 @@ Open Tailor Platform Console. **Usage** ``` -tailor-sdk open [options] +tailor open [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -277,7 +274,7 @@ Call Tailor Platform API endpoints directly. **Usage** ``` -tailor-sdk api [options] [command] +tailor api [options] [command] ``` **Arguments** @@ -288,13 +285,13 @@ tailor-sdk api [options] [command] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | --------------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--body ` | `-b` | Request body as JSON. | No | `"{}"` | - | -| `--field ` | `-f` | Set a body field as `key=value` (repeatable; dotted keys nest). Overrides --body. | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | --------------------------------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--body ` | `-b` | Request body as JSON. | No | `"{}"` | - | +| `--field ` | `-f` | Set a body field as `key=value` (repeatable; dotted keys nest). Overrides --body. | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -310,30 +307,30 @@ See [Global Options](../cli-reference.md#global-options) for options available t **Call an endpoint; workspaceId is auto-injected.** ```bash -$ tailor-sdk api GetApplication -b '{"applicationName":"app-1"}' +$ tailor api GetApplication -b '{"applicationName":"app-1"}' ``` **Same as above, using --field instead of --body.** ```bash -$ tailor-sdk api GetApplication -f applicationName=app-1 +$ tailor api GetApplication -f applicationName=app-1 ``` **List all invocable OperatorService methods.** ```bash -$ tailor-sdk api list +$ tailor api list ``` **Show the input message tree for an endpoint.** ```bash -$ tailor-sdk api inspect GetApplication +$ tailor api inspect GetApplication ``` **Notes** -Use `tailor-sdk api list` to enumerate invocable methods and `tailor-sdk api inspect ` to print an endpoint's input message tree (combine with `--json` for machine-readable output). +Use `tailor api list` to enumerate invocable methods and `tailor api inspect ` to print an endpoint's input message tree (combine with `--json` for machine-readable output). The request body is inferred from the target endpoint's request schema, and commonly required fields are auto-injected so they can be omitted from `--body`: @@ -353,7 +350,7 @@ Print the input message tree of an OperatorService endpoint. **Usage** ``` -tailor-sdk api inspect +tailor api inspect ``` **Arguments** @@ -369,18 +366,18 @@ See [Global Options](../cli-reference.md#global-options) for options available t **Show fields of GetApplicationRequest.** ```bash -$ tailor-sdk api inspect GetApplication +$ tailor api inspect GetApplication ``` **Inspect a deeply nested input with `(oneof config)` annotations.** ```bash -$ tailor-sdk api inspect CreateExecutorExecutor +$ tailor api inspect CreateExecutorExecutor ``` **Notes** -Combine with the global `--json` flag for a machine-readable descriptor. Recursive type references and `oneof` membership are annotated. Use `tailor-sdk api list` to discover endpoint names. +Combine with the global `--json` flag for a machine-readable descriptor. Recursive type references and `oneof` membership are annotated. Use `tailor api list` to discover endpoint names. ### api list @@ -389,7 +386,7 @@ List all invocable OperatorService methods. **Usage** ``` -tailor-sdk api list +tailor api list ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. diff --git a/packages/sdk/docs/cli/application.template.md b/packages/sdk/docs/cli/application.template.md index 18bc9a8ce..441497b84 100644 --- a/packages/sdk/docs/cli/application.template.md +++ b/packages/sdk/docs/cli/application.template.md @@ -63,7 +63,7 @@ On first run, `deploy` automatically injects a stable `id: ""` field into To deploy interdependent applications to the same workspace in one run, pass comma-separated config paths: ```bash -tailor-sdk deploy --config apps/buyer/tailor.config.ts,apps/supplier/tailor.config.ts +tailor deploy --config apps/buyer/tailor.config.ts,apps/supplier/tailor.config.ts ``` When multiple configs are provided, `deploy` creates or updates all configured services first, then updates the applications. This lets one application reference resources owned by another config with `external: true` during the same deploy. @@ -110,7 +110,7 @@ Plan: 5 to create, 3 to update, 1 to delete Use `--dry-run` to preview the plan without applying anything. In dry-run mode the plan is written to **stdout**, so it can be captured in CI without `2>&1`: ```bash -tailor-sdk deploy --dry-run > plan.txt +tailor deploy --dry-run > plan.txt ``` In apply mode, the plan is printed to stderr so it does not interfere with piped output. diff --git a/packages/sdk/docs/cli/auth.md b/packages/sdk/docs/cli/auth.md index e2427ada9..f24bd4b6d 100644 --- a/packages/sdk/docs/cli/auth.md +++ b/packages/sdk/docs/cli/auth.md @@ -9,7 +9,7 @@ Manage auth connections. **Usage** ``` -tailor-sdk authconnection [command] +tailor authconnection [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -31,7 +31,7 @@ Authorize an auth connection via OAuth2 flow. **Usage** ``` -tailor-sdk authconnection authorize [options] +tailor authconnection authorize [options] ``` **Options** @@ -54,7 +54,7 @@ Delete an auth connection entirely. **Usage** ``` -tailor-sdk authconnection delete [options] +tailor authconnection delete [options] ``` **Options** @@ -75,7 +75,7 @@ List all auth connections. **Usage** ``` -tailor-sdk authconnection list [options] +tailor authconnection list [options] ``` **Options** @@ -96,7 +96,7 @@ Open the auth connections page in the Tailor Platform Console. **Usage** ``` -tailor-sdk authconnection open [options] +tailor authconnection open [options] ``` **Options** @@ -115,7 +115,7 @@ Revoke an auth connection's tokens (keeps the connection; use 'delete' to remove **Usage** ``` -tailor-sdk authconnection revoke [options] +tailor authconnection revoke [options] ``` **Options** @@ -140,7 +140,7 @@ Manage machine users in your Tailor Platform application. **Usage** ``` -tailor-sdk machineuser [command] +tailor machineuser [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -159,18 +159,18 @@ List all machine users in the application. **Usage** ``` -tailor-sdk machineuser list [options] +tailor machineuser list [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | -------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--order ` | - | Sort order (asc or desc) | No | `"desc"` | - | -| `--limit ` | `-l` | Maximum number of items to return (0 or omit: unlimited) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--order ` | - | Sort order (asc or desc) | No | `"desc"` | - | +| `--limit ` | `-l` | Maximum number of items to return (0 or omit: unlimited) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -181,7 +181,7 @@ Get an access token for a machine user. **Usage** ``` -tailor-sdk machineuser token [options] [name] +tailor machineuser token [options] [name] ``` **Arguments** @@ -192,11 +192,11 @@ tailor-sdk machineuser token [options] [name] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -207,7 +207,7 @@ Manage OAuth2 clients in your Tailor Platform application. **Usage** ``` -tailor-sdk oauth2client [command] +tailor oauth2client [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -226,18 +226,18 @@ List all OAuth2 clients in the application. **Usage** ``` -tailor-sdk oauth2client list [options] +tailor oauth2client list [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | -------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--order ` | - | Sort order (asc or desc) | No | `"desc"` | - | -| `--limit ` | `-l` | Maximum number of items to return (0 or omit: unlimited) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--order ` | - | Sort order (asc or desc) | No | `"desc"` | - | +| `--limit ` | `-l` | Maximum number of items to return (0 or omit: unlimited) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -259,7 +259,7 @@ Get OAuth2 client credentials (including client secret). **Usage** ``` -tailor-sdk oauth2client get [options] +tailor oauth2client get [options] ``` **Arguments** @@ -270,11 +270,11 @@ tailor-sdk oauth2client get [options] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | See [Global Options](../cli-reference.md#global-options) for options available to all commands. diff --git a/packages/sdk/docs/cli/completion.md b/packages/sdk/docs/cli/completion.md index d85dc1788..330341e05 100644 --- a/packages/sdk/docs/cli/completion.md +++ b/packages/sdk/docs/cli/completion.md @@ -7,7 +7,7 @@ Generate shell completion script **Usage** ``` -tailor-sdk completion [options] [shell] +tailor completion [options] [shell] ``` **Arguments** diff --git a/packages/sdk/docs/cli/crashreport.md b/packages/sdk/docs/cli/crashreport.md index 28b39997f..4a95b3430 100644 --- a/packages/sdk/docs/cli/crashreport.md +++ b/packages/sdk/docs/cli/crashreport.md @@ -6,12 +6,10 @@ Commands for managing crash reports. Manage crash reports. -**Aliases:** `crash-report` - **Usage** ``` -tailor-sdk crashreport [command] +tailor crashreport [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -30,7 +28,7 @@ List local crash report files. **Usage** ``` -tailor-sdk crashreport list [options] +tailor crashreport list [options] ``` **Options** @@ -49,7 +47,7 @@ Submit a crash report to help improve the SDK. **Usage** ``` -tailor-sdk crashreport send [options] +tailor crashreport send [options] ``` **Options** diff --git a/packages/sdk/docs/cli/executor.md b/packages/sdk/docs/cli/executor.md index 55a6dea49..c78d37749 100644 --- a/packages/sdk/docs/cli/executor.md +++ b/packages/sdk/docs/cli/executor.md @@ -9,7 +9,7 @@ Manage executors **Usage** ``` -tailor-sdk executor [command] +tailor executor [command] ``` **Commands** @@ -31,7 +31,7 @@ List all executors **Usage** ``` -tailor-sdk executor list [options] +tailor executor list [options] ``` **Options** @@ -52,7 +52,7 @@ Get executor details **Usage** ``` -tailor-sdk executor get [options] +tailor executor get [options] ``` **Arguments** @@ -77,7 +77,7 @@ List or get executor jobs. **Usage** ``` -tailor-sdk executor jobs [options] [job-id] +tailor executor jobs [options] [job-id] ``` **Arguments** @@ -109,43 +109,43 @@ See [Global Options](../cli-reference.md#global-options) for options available t **List jobs for an executor (default: 50 jobs)** ```bash -$ tailor-sdk executor jobs my-executor +$ tailor executor jobs my-executor ``` **Limit the number of jobs** ```bash -$ tailor-sdk executor jobs my-executor --limit 10 +$ tailor executor jobs my-executor --limit 10 ``` **Filter by status** ```bash -$ tailor-sdk executor jobs my-executor -s RUNNING +$ tailor executor jobs my-executor -s RUNNING ``` **Get job details** ```bash -$ tailor-sdk executor jobs my-executor +$ tailor executor jobs my-executor ``` **Get job details with attempts** ```bash -$ tailor-sdk executor jobs my-executor --attempts +$ tailor executor jobs my-executor --attempts ``` **Wait for job to complete** ```bash -$ tailor-sdk executor jobs my-executor -W +$ tailor executor jobs my-executor -W ``` **Wait for job with logs** ```bash -$ tailor-sdk executor jobs my-executor -W -l +$ tailor executor jobs my-executor -W -l ``` ### executor trigger @@ -155,7 +155,7 @@ Trigger an executor manually. **Usage** ``` -tailor-sdk executor trigger [options] +tailor executor trigger [options] ``` **Arguments** @@ -182,31 +182,31 @@ tailor-sdk executor trigger [options] **Trigger an executor** ```bash -$ tailor-sdk executor trigger my-executor +$ tailor executor trigger my-executor ``` **Trigger with data** ```bash -$ tailor-sdk executor trigger my-executor -d '{"message": "hello"}' +$ tailor executor trigger my-executor -d '{"message": "hello"}' ``` **Trigger with data and headers** ```bash -$ tailor-sdk executor trigger my-executor -d '{"message": "hello"}' -H "X-Custom: value" -H "X-Another: value2" +$ tailor executor trigger my-executor -d '{"message": "hello"}' -H "X-Custom: value" -H "X-Another: value2" ``` **Trigger and wait for completion** ```bash -$ tailor-sdk executor trigger my-executor -W +$ tailor executor trigger my-executor -W ``` **Trigger, wait, and show logs** ```bash -$ tailor-sdk executor trigger my-executor -W -l +$ tailor executor trigger my-executor -W -l ``` **Shell automation** @@ -215,7 +215,7 @@ Trigger an executor and wait for the executor job plus any downstream workflow o function execution: ```bash -tailor-sdk executor trigger daily-workflow \ +tailor executor trigger daily-workflow \ --wait \ --timeout 5m \ --interval 5s \ @@ -225,7 +225,7 @@ tailor-sdk executor trigger daily-workflow \ Wait for an existing job when another process already captured the job ID: ```bash -tailor-sdk executor jobs daily-workflow "$job_id" \ +tailor executor jobs daily-workflow "$job_id" \ --wait \ --timeout 5m \ --logs \ @@ -286,7 +286,7 @@ Manage executor webhooks **Usage** ``` -tailor-sdk executor webhook [command] +tailor executor webhook [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -304,7 +304,7 @@ List executors with incoming webhook triggers **Usage** ``` -tailor-sdk executor webhook list [options] +tailor executor webhook list [options] ``` **Options** diff --git a/packages/sdk/docs/cli/executor.template.md b/packages/sdk/docs/cli/executor.template.md index c1e30b1fe..46752b5a3 100644 --- a/packages/sdk/docs/cli/executor.template.md +++ b/packages/sdk/docs/cli/executor.template.md @@ -40,7 +40,7 @@ Trigger an executor and wait for the executor job plus any downstream workflow o function execution: ```bash -tailor-sdk executor trigger daily-workflow \ +tailor executor trigger daily-workflow \ --wait \ --timeout 5m \ --interval 5s \ @@ -50,7 +50,7 @@ tailor-sdk executor trigger daily-workflow \ Wait for an existing job when another process already captured the job ID: ```bash -tailor-sdk executor jobs daily-workflow "$job_id" \ +tailor executor jobs daily-workflow "$job_id" \ --wait \ --timeout 5m \ --logs \ diff --git a/packages/sdk/docs/cli/function.md b/packages/sdk/docs/cli/function.md index e5ab94729..e5db796fc 100644 --- a/packages/sdk/docs/cli/function.md +++ b/packages/sdk/docs/cli/function.md @@ -9,7 +9,7 @@ Manage functions **Usage** ``` -tailor-sdk function [command] +tailor function [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -30,7 +30,7 @@ Get a function registry by name **Usage** ``` -tailor-sdk function get [options] +tailor function get [options] ``` **Options** @@ -50,7 +50,7 @@ List function registries in a workspace **Usage** ``` -tailor-sdk function list [options] +tailor function list [options] ``` **Options** @@ -71,7 +71,7 @@ List or get function execution logs. **Usage** ``` -tailor-sdk function logs [options] [execution-id] +tailor function logs [options] [execution-id] ``` **Arguments** @@ -96,32 +96,32 @@ See [Global Options](../cli-reference.md#global-options) for options available t **List all function execution logs** ```bash -$ tailor-sdk function logs +$ tailor function logs ``` **Get execution details with logs** ```bash -$ tailor-sdk function logs +$ tailor function logs ``` **Output as JSON** ```bash -$ tailor-sdk function logs --json +$ tailor function logs --json ``` **Get execution details as JSON** ```bash -$ tailor-sdk function logs --json +$ tailor function logs --json ``` **Notes** When viewing a specific execution that failed, the command displays error details with the stack trace mapped back to your original source files (clickable file links and code snippets, matching `function test-run` output). -Stack traces stay accurate even after later redeploys, because the trace is resolved against the exact build that produced the execution. If that build is no longer available, the command falls back to a plain-text error display. +Stack traces are mapped only when the execution includes a content hash for the exact build that ran. If the content hash is missing or the build is no longer available, the command falls back to a plain-text error display. ### function test-run @@ -130,7 +130,7 @@ Run a function on the Tailor Platform server without deploying. **Usage** ``` -tailor-sdk function test-run [options] +tailor function test-run [options] ``` **Arguments** @@ -157,19 +157,19 @@ See [Global Options](../cli-reference.md#global-options) for options available t **Run a resolver with input arguments** ```bash -$ tailor-sdk function test-run resolvers/add.ts --arg '{"a":1,"b":2}' +$ tailor function test-run resolvers/add.ts --arg '{"a":1,"b":2}' ``` **Run a specific workflow job by name** ```bash -$ tailor-sdk function test-run workflows/sample.ts --name validate-order +$ tailor function test-run workflows/sample.ts --name validate-order ``` **Run a pre-bundled .js file directly** ```bash -$ tailor-sdk function test-run build/resolvers/add.js --arg '{"a":1,"b":2}' +$ tailor function test-run build/resolvers/add.js --arg '{"a":1,"b":2}' ``` **Notes** @@ -178,5 +178,5 @@ You can pass either a source file (`.ts`) or a pre-bundled file (`.js`). When a `.js` file is provided, detection and bundling are skipped and the file is executed as-is. > [!WARNING] -> Workflow job `.trigger()` calls do not work in test-run mode. -> Triggered jobs are not executed; only the target job's `body` function runs in isolation. +> Workflow job `.start()` calls do not work in test-run mode. +> Started jobs are not executed; only the target job's `body` function runs in isolation. diff --git a/packages/sdk/docs/cli/organization.md b/packages/sdk/docs/cli/organization.md index f1e29ec6d..23020746e 100644 --- a/packages/sdk/docs/cli/organization.md +++ b/packages/sdk/docs/cli/organization.md @@ -5,7 +5,7 @@ Manage Tailor Platform organizations. **Usage** ``` -tailor-sdk organization [command] +tailor organization [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -27,7 +27,7 @@ Manage organization folders. **Usage** ``` -tailor-sdk organization folder +tailor organization folder ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -49,7 +49,7 @@ Create a new folder in an organization. **Usage** ``` -tailor-sdk organization folder create [options] +tailor organization folder create [options] ``` **Options** @@ -69,7 +69,7 @@ Delete a folder from an organization. **Usage** ``` -tailor-sdk organization folder delete [options] +tailor organization folder delete [options] ``` **Options** @@ -89,7 +89,7 @@ Show detailed information about a folder. **Usage** ``` -tailor-sdk organization folder get [options] +tailor organization folder get [options] ``` **Options** @@ -108,7 +108,7 @@ List folders in an organization. **Usage** ``` -tailor-sdk organization folder list [options] +tailor organization folder list [options] ``` **Options** @@ -129,7 +129,7 @@ Update a folder's name. **Usage** ``` -tailor-sdk organization folder update [options] +tailor organization folder update [options] ``` **Options** @@ -149,7 +149,7 @@ Show detailed information about an organization. **Usage** ``` -tailor-sdk organization get [options] +tailor organization get [options] ``` **Options** @@ -167,7 +167,7 @@ List organizations you belong to. **Usage** ``` -tailor-sdk organization list [options] +tailor organization list [options] ``` **Options** @@ -185,7 +185,7 @@ Display organization folder hierarchy as a tree. **Usage** ``` -tailor-sdk organization tree [options] +tailor organization tree [options] ``` **Options** @@ -204,7 +204,7 @@ Update an organization's name. **Usage** ``` -tailor-sdk organization update [options] +tailor organization update [options] ``` **Options** diff --git a/packages/sdk/docs/cli/plugin.md b/packages/sdk/docs/cli/plugin.md new file mode 100644 index 000000000..5f34ecd7f --- /dev/null +++ b/packages/sdk/docs/cli/plugin.md @@ -0,0 +1,29 @@ +## plugin + +Manage and inspect CLI plugins (beta). + +**Usage** + +``` +tailor plugin [command] +``` + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + +**Commands** + +| Command | Description | +| ----------------------------- | ---------------------------------------------------------------------------------------- | +| [`plugin list`](#plugin-list) | List discovered plugins (executables named `-` on PATH or node_modules/.bin). | + +### plugin list + +List discovered plugins (executables named `-` on PATH or node_modules/.bin). + +**Usage** + +``` +tailor plugin list +``` + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. diff --git a/packages/sdk/docs/cli/plugin.template.md b/packages/sdk/docs/cli/plugin.template.md new file mode 100644 index 000000000..01b67cfdd --- /dev/null +++ b/packages/sdk/docs/cli/plugin.template.md @@ -0,0 +1,8 @@ +--- +politty: + index: + title: "Plugin Commands" + description: "Discover and inspect CLI plugins (external `tailor-` executables)." +--- + +{{politty:command:plugin}} diff --git a/packages/sdk/docs/cli/query.md b/packages/sdk/docs/cli/query.md index 567f99425..5683caa03 100644 --- a/packages/sdk/docs/cli/query.md +++ b/packages/sdk/docs/cli/query.md @@ -5,7 +5,7 @@ Run SQL/GraphQL query. **Usage** ``` -tailor-sdk query [options] +tailor query [options] ``` **Options** @@ -14,7 +14,7 @@ tailor-sdk query [options] | ------------------------------- | ----- | ---------------------------------------------------------------------------------------------------- | -------- | -------------------- | ----------------------------------- | | `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | | `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | | `--engine ` | - | Query engine (sql or gql) | Yes | - | - | | `--query ` | `-q` | Query string to execute directly; omit to start REPL mode | No | - | - | | `--file ` | `-f` | Read query string from file; omit to start REPL mode | No | - | - | diff --git a/packages/sdk/docs/cli/secret.md b/packages/sdk/docs/cli/secret.md index 5f93a04b9..0d08bb31a 100644 --- a/packages/sdk/docs/cli/secret.md +++ b/packages/sdk/docs/cli/secret.md @@ -9,7 +9,7 @@ Manage Secret Manager vaults and secrets. **Usage** ``` -tailor-sdk secret [command] +tailor secret [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -31,7 +31,7 @@ Create a secret in a vault. **Usage** ``` -tailor-sdk secret create [options] +tailor secret create [options] ``` **Options** @@ -54,7 +54,7 @@ Delete a secret in a vault. **Usage** ``` -tailor-sdk secret delete [options] +tailor secret delete [options] ``` **Options** @@ -76,7 +76,7 @@ List all secrets in a vault. **Usage** ``` -tailor-sdk secret list [options] +tailor secret list [options] ``` **Options** @@ -98,7 +98,7 @@ Update a secret in a vault. **Usage** ``` -tailor-sdk secret update [options] +tailor secret update [options] ``` **Options** @@ -121,7 +121,7 @@ Manage Secret Manager vaults. **Usage** ``` -tailor-sdk secret vault [command] +tailor secret vault [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -141,7 +141,7 @@ Create a new Secret Manager vault. **Usage** ``` -tailor-sdk secret vault create [options] +tailor secret vault create [options] ``` **Arguments** @@ -166,7 +166,7 @@ Delete a Secret Manager vault. **Usage** ``` -tailor-sdk secret vault delete [options] +tailor secret vault delete [options] ``` **Arguments** @@ -192,7 +192,7 @@ List all Secret Manager vaults in the workspace. **Usage** ``` -tailor-sdk secret vault list [options] +tailor secret vault list [options] ``` **Options** diff --git a/packages/sdk/docs/cli/setup.md b/packages/sdk/docs/cli/setup.md index b7aec444e..3bb562e12 100644 --- a/packages/sdk/docs/cli/setup.md +++ b/packages/sdk/docs/cli/setup.md @@ -9,7 +9,7 @@ Generate CI deploy workflows for your project. (beta) **Usage** ``` -tailor-sdk setup +tailor setup ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -24,7 +24,7 @@ See [Global Options](../cli-reference.md#global-options) for options available t | [`setup action`](#setup-action) | Generate a per-app composite action for use with setup coordinate (monorepo multi-app deploys). | | [`setup coordinate`](#setup-coordinate) | Generate a coordinator workflow that orchestrates multiple --action-generated composite actions. | | [`setup check`](#setup-check) | Audit generated workflows for drift against the current config/repo (read-only). | -| [`setup delete`](#setup-delete) | Delete managed workflow/action file(s) and their .github/tailor-sdk.lock entries. | +| [`setup delete`](#setup-delete) | Delete managed workflow/action file(s) and their .github/tailor.lock entries. | ### setup action @@ -33,7 +33,7 @@ Generate a per-app composite action for use with setup coordinate (monorepo mult **Usage** ``` -tailor-sdk setup action [options] +tailor setup action [options] ``` **Options** @@ -54,7 +54,7 @@ Generate a branch-target deploy workflow (push to branch triggers deploy). **Usage** ``` -tailor-sdk setup branch [options] +tailor setup branch [options] ``` **Options** @@ -77,7 +77,7 @@ Audit generated workflows for drift against the current config/repo (read-only). **Usage** ``` -tailor-sdk setup check [options] +tailor setup check [options] ``` **Options** @@ -95,7 +95,7 @@ Generate a coordinator workflow that orchestrates multiple --action-generated co **Usage** ``` -tailor-sdk setup coordinate [options] +tailor setup coordinate [options] ``` **Options** @@ -113,12 +113,12 @@ See [Global Options](../cli-reference.md#global-options) for options available t ### setup delete -Delete managed workflow/action file(s) and their .github/tailor-sdk.lock entries. +Delete managed workflow/action file(s) and their .github/tailor.lock entries. **Usage** ``` -tailor-sdk setup delete [options] +tailor setup delete [options] ``` **Arguments** @@ -142,7 +142,7 @@ Generate a preview workflow (PR open/sync triggers deploy to a per-PR workspace) **Usage** ``` -tailor-sdk setup preview [options] +tailor setup preview [options] ``` **Options** @@ -166,7 +166,7 @@ Generate a tag-target deploy workflow (tag push triggers deploy). **Usage** ``` -tailor-sdk setup tag [options] +tailor setup tag [options] ``` **Options** diff --git a/packages/sdk/docs/cli/skills.md b/packages/sdk/docs/cli/skills.md index 17fcb873a..4f5a946a8 100644 --- a/packages/sdk/docs/cli/skills.md +++ b/packages/sdk/docs/cli/skills.md @@ -5,32 +5,82 @@ Manage Tailor SDK agent skills. **Usage** ``` -tailor-sdk skills [command] +tailor skills [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. **Commands** -| Command | Description | -| ----------------------------------- | ------------------------------------------------------------------ | -| [`skills install`](#skills-install) | Install the tailor-sdk agent skill from the installed SDK package. | +| Command | Description | +| --------------------------------- | --------------------------------------------- | +| [`skills sync`](#skills-sync) | Remove and reinstall Tailor SDK agent skills. | +| [`skills add`](#skills-add) | Install Tailor SDK agent skills. | +| [`skills remove`](#skills-remove) | Remove installed Tailor SDK agent skills. | +| [`skills list`](#skills-list) | List Tailor SDK agent skills. | -### skills install +### skills add -Install the tailor-sdk agent skill from the installed SDK package. +Install Tailor SDK agent skills. **Usage** ``` -tailor-sdk skills install [options] +tailor skills add [name] +``` + +**Arguments** + +| Argument | Description | Required | +| -------- | --------------------------------------- | -------- | +| `name` | Skill name(s) to install (default: all) | No | + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + +### skills list + +List Tailor SDK agent skills. + +**Usage** + +``` +tailor skills list +``` + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + +### skills remove + +Remove installed Tailor SDK agent skills. + +**Usage** + +``` +tailor skills remove [name] +``` + +**Arguments** + +| Argument | Description | Required | +| -------- | ----------------------------------- | -------- | +| `name` | Skill name to remove (default: all) | No | + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + +### skills sync + +Remove and reinstall Tailor SDK agent skills. + +**Usage** + +``` +tailor skills sync [options] ``` **Options** -| Option | Alias | Description | Required | Default | -| ----------------- | ----- | ---------------------------------------------------------------------------- | -------- | --------------- | -| `--agent ` | `-a` | vercel/skills agent name (e.g. claude-code, codex). Defaults to claude-code. | No | `"claude-code"` | -| `--yes` | `-y` | Auto-approve prompts. | No | `false` | +| Option | Alias | Description | Required | Default | +| --------------------- | ----- | -------------------------------- | -------- | ------- | +| `--exclude ` | `-x` | Skill names to exclude from sync | No | `[]` | See [Global Options](../cli-reference.md#global-options) for options available to all commands. diff --git a/packages/sdk/docs/cli/skills.template.md b/packages/sdk/docs/cli/skills.template.md index 78f3dd82c..05b32feb4 100644 --- a/packages/sdk/docs/cli/skills.template.md +++ b/packages/sdk/docs/cli/skills.template.md @@ -2,7 +2,7 @@ politty: index: title: "Skills Commands" - description: "Commands for installing Tailor SDK agent skills." + description: "Commands for managing Tailor SDK agent skills." --- {{politty:command:skills}} diff --git a/packages/sdk/docs/cli/staticwebsite.md b/packages/sdk/docs/cli/staticwebsite.md index e899c158e..1a6ffa768 100644 --- a/packages/sdk/docs/cli/staticwebsite.md +++ b/packages/sdk/docs/cli/staticwebsite.md @@ -9,7 +9,7 @@ Manage static websites in your workspace. **Usage** ``` -tailor-sdk staticwebsite [command] +tailor staticwebsite [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -30,7 +30,7 @@ Deploy a static website from a local build directory. **Usage** ``` -tailor-sdk staticwebsite deploy [options] +tailor staticwebsite deploy [options] ``` **Options** @@ -48,10 +48,10 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # Deploy a static website from the dist directory -tailor-sdk staticwebsite deploy --name my-website --dir ./dist +tailor staticwebsite deploy --name my-website --dir ./dist # Deploy with workspace ID -tailor-sdk staticwebsite deploy -n my-website -d ./dist -w ws_abc123 +tailor staticwebsite deploy -n my-website -d ./dist -w ws_abc123 ``` **Notes:** @@ -68,7 +68,7 @@ List all static websites in a workspace. **Usage** ``` -tailor-sdk staticwebsite list [options] +tailor staticwebsite list [options] ``` **Options** @@ -86,10 +86,10 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # List all static websites -tailor-sdk staticwebsite list +tailor staticwebsite list # List with JSON output -tailor-sdk staticwebsite list --json +tailor staticwebsite list --json ``` ### staticwebsite domain @@ -99,7 +99,7 @@ Manage custom domains for static websites. **Usage** ``` -tailor-sdk staticwebsite domain +tailor staticwebsite domain ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -118,7 +118,7 @@ Get details of a custom domain. **Usage** ``` -tailor-sdk staticwebsite domain get [options] +tailor staticwebsite domain get [options] ``` **Arguments** @@ -143,7 +143,7 @@ List custom domains for a static website. **Usage** ``` -tailor-sdk staticwebsite domain list [options] +tailor staticwebsite domain list [options] ``` **Arguments** @@ -168,7 +168,7 @@ Get details of a specific static website. **Usage** ``` -tailor-sdk staticwebsite get [options] +tailor staticwebsite get [options] ``` **Arguments** @@ -190,8 +190,8 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # Get details of a static website -tailor-sdk staticwebsite get my-website +tailor staticwebsite get my-website # Get with JSON output -tailor-sdk staticwebsite get my-website --json +tailor staticwebsite get my-website --json ``` diff --git a/packages/sdk/docs/cli/staticwebsite.template.md b/packages/sdk/docs/cli/staticwebsite.template.md index 708af789d..9fe41c137 100644 --- a/packages/sdk/docs/cli/staticwebsite.template.md +++ b/packages/sdk/docs/cli/staticwebsite.template.md @@ -25,10 +25,10 @@ Commands for managing and deploying static websites. ```bash # Deploy a static website from the dist directory -tailor-sdk staticwebsite deploy --name my-website --dir ./dist +tailor staticwebsite deploy --name my-website --dir ./dist # Deploy with workspace ID -tailor-sdk staticwebsite deploy -n my-website -d ./dist -w ws_abc123 +tailor staticwebsite deploy -n my-website -d ./dist -w ws_abc123 ``` **Notes:** @@ -44,10 +44,10 @@ tailor-sdk staticwebsite deploy -n my-website -d ./dist -w ws_abc123 ```bash # List all static websites -tailor-sdk staticwebsite list +tailor staticwebsite list # List with JSON output -tailor-sdk staticwebsite list --json +tailor staticwebsite list --json ``` {{politty:command:staticwebsite domain}} @@ -57,8 +57,8 @@ tailor-sdk staticwebsite list --json ```bash # Get details of a static website -tailor-sdk staticwebsite get my-website +tailor staticwebsite get my-website # Get with JSON output -tailor-sdk staticwebsite get my-website --json +tailor staticwebsite get my-website --json ``` diff --git a/packages/sdk/docs/cli/tailordb.md b/packages/sdk/docs/cli/tailordb.md index 990ab38e7..3c5825c05 100644 --- a/packages/sdk/docs/cli/tailordb.md +++ b/packages/sdk/docs/cli/tailordb.md @@ -9,18 +9,17 @@ Manage TailorDB tables and data. **Usage** ``` -tailor-sdk tailordb +tailor tailordb ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. **Commands** -| Command | Description | -| ------------------------------------------- | ------------------------------------------------------------------------- | -| [`tailordb truncate`](#tailordb-truncate) | Truncate (delete all records from) TailorDB tables. | -| [`tailordb migration`](#tailordb-migration) | Manage TailorDB schema migrations. | -| [`tailordb erd`](#tailordb-erd) | Generate TailorDB ERD viewer artifacts from local TailorDB schema. (beta) | +| Command | Description | +| ------------------------------------------- | --------------------------------------------------- | +| [`tailordb truncate`](#tailordb-truncate) | Truncate (delete all records from) TailorDB tables. | +| [`tailordb migration`](#tailordb-migration) | Manage TailorDB schema migrations. | ### tailordb truncate @@ -29,7 +28,7 @@ Truncate (delete all records from) TailorDB tables. **Usage** ``` -tailor-sdk tailordb truncate [options] [types] +tailor tailordb truncate [options] [types] ``` **Arguments** @@ -40,14 +39,14 @@ tailor-sdk tailordb truncate [options] [types] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | -------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | -| `--all` | `-a` | Truncate all tables in all owned namespaces (excludes external namespaces) | No | `false` | - | -| `--namespace ` | `-n` | Truncate all tables in specified namespace | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | +| `--all` | `-a` | Truncate all tables in all owned namespaces (excludes external namespaces) | No | `false` | - | +| `--namespace ` | `-n` | Truncate all tables in specified namespace | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -55,19 +54,19 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # Truncate all tables in all namespaces (requires confirmation) -tailor-sdk tailordb truncate --all +tailor tailordb truncate --all # Truncate all tables in all namespaces (skip confirmation) -tailor-sdk tailordb truncate --all --yes +tailor tailordb truncate --all --yes # Truncate all tables in a specific namespace -tailor-sdk tailordb truncate --namespace myNamespace +tailor tailordb truncate --namespace myNamespace # Truncate specific types (namespace is auto-detected) -tailor-sdk tailordb truncate User Post Comment +tailor tailordb truncate User Post Comment # Truncate specific types with confirmation skipped -tailor-sdk tailordb truncate User Post --yes +tailor tailordb truncate User Post --yes ``` **Notes:** @@ -85,12 +84,12 @@ tailor-sdk tailordb truncate User Post --yes Manage TailorDB schema migrations. -Note: Migration scripts are automatically executed during `tailor-sdk deploy`. See [Automatic Migration Execution](../services/tailordb-migration.md#automatic-migration-execution) for details. +Note: Migration scripts are automatically executed during `tailor deploy`. See [Automatic Migration Execution](../services/tailordb-migration.md#automatic-migration-execution) for details. **Usage** ``` -tailor-sdk tailordb migration +tailor tailordb migration ``` **Commands** @@ -112,17 +111,17 @@ Generate migration files by detecting schema differences between current local t **Usage** ``` -tailor-sdk tailordb migration generate [options] +tailor tailordb migration generate [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------- | ----- | ------------------------------------------ | -------- | -------------------- | --------------------------------- | -| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--name ` | `-n` | Optional description for the migration | No | - | - | -| `--init` | - | Delete existing migrations and start fresh | No | `false` | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------- | ----- | ------------------------------------------ | -------- | -------------------- | -------------------- | +| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--name ` | `-n` | Optional description for the migration | No | - | - | +| `--init` | - | Delete existing migrations and start fresh | No | `false` | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -133,7 +132,7 @@ Add a migration script (migrate.ts) template to an existing migration directory. **Usage** ``` -tailor-sdk tailordb migration script [options] +tailor tailordb migration script [options] ``` **Arguments** @@ -144,10 +143,10 @@ tailor-sdk tailordb migration script [options] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | -------------------- | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -158,7 +157,7 @@ Set migration checkpoint to a specific number. **Usage** ``` -tailor-sdk tailordb migration set [options] +tailor tailordb migration set [options] ``` **Arguments** @@ -169,13 +168,13 @@ tailor-sdk tailordb migration set [options] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | -| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | +| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -186,17 +185,17 @@ Show the current migration status for TailorDB namespaces, including applied and **Usage** ``` -tailor-sdk tailordb migration status [options] +tailor tailordb migration status [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--namespace ` | `-n` | Target TailorDB namespace (shows all namespaces if not specified) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--namespace ` | `-n` | Target TailorDB namespace (shows all namespaces if not specified) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -207,7 +206,7 @@ Sync remote TailorDB schema to a specific migration snapshot (recovery from --no **Usage** ``` -tailor-sdk tailordb migration sync [options] +tailor tailordb migration sync [options] ``` **Arguments** @@ -218,13 +217,13 @@ tailor-sdk tailordb migration sync [options] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | -| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | +| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -232,142 +231,11 @@ See [Global Options](../cli-reference.md#global-options) for options available t ### tailordb erd -Generate TailorDB ERD viewer artifacts from local TailorDB schema. (beta) - -**Usage** - -``` -tailor-sdk tailordb erd -``` - -See [Global Options](../cli-reference.md#global-options) for options available to all commands. - -**Commands** - -| Command | Description | -| --------------------------------------------- | ----------------------------------------------------------------- | -| [`tailordb erd export`](#tailordb-erd-export) | Export TailorDB ERD static viewer from local TailorDB schema. | -| [`tailordb erd diff`](#tailordb-erd-diff) | Render TailorDB ERD schema diff HTML from exported ERD viewers. | -| [`tailordb erd serve`](#tailordb-erd-serve) | Generate and serve TailorDB ERD locally with watch reload. (beta) | -| [`tailordb erd deploy`](#tailordb-erd-deploy) | Deploy ERD static website for TailorDB namespace(s). | - -#### tailordb erd export - -Export TailorDB ERD static viewer from local TailorDB schema. - -**Usage** - -``` -tailor-sdk tailordb erd export [options] -``` - -**Options** - -| Option | Alias | Description | Required | Default | Env | -| ------------------------- | ----- | ---------------------------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--namespace ` | `-n` | TailorDB namespace name (optional if only one namespace is defined in config) | No | - | - | -| `--output ` | `-o` | Output directory path for TailorDB ERD viewer files (writes to `//dist`) | No | `".tailor-sdk/erd"` | - | - -See [Global Options](../cli-reference.md#global-options) for options available to all commands. - -#### tailordb erd diff - -Render TailorDB ERD schema diff HTML from exported ERD viewers. - -**Usage** - -``` -tailor-sdk tailordb erd diff [options] -``` - -**Options** - -| Option | Alias | Description | Required | Default | -| ----------------------------- | ----- | ----------------------------------------------------------------------- | -------- | ------- | -| `--base-html ` | - | Base ERD viewer HTML file | No | - | -| `--head-html ` | - | Head ERD viewer HTML file | No | - | -| `--namespace ` | `-n` | TailorDB namespace name (defaults to the provided ERD schema namespace) | No | - | -| `--output ` | `-o` | Output ERD diff HTML file | Yes | - | -| `--output-json ` | - | Optional output JSON file for the computed diff | No | - | - -See [Global Options](../cli-reference.md#global-options) for options available to all commands. - -#### tailordb erd serve - -Generate and serve TailorDB ERD locally with watch reload. (beta) - -**Usage** - -``` -tailor-sdk tailordb erd serve [options] -``` - -**Options** - -| Option | Alias | Description | Required | Default | Env | -| ------------------------- | ----- | ------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--namespace ` | `-n` | TailorDB namespace name (uses first namespace in config if not specified) | No | - | - | -| `--port ` | - | Local server port (0 selects a free port) | No | `0` | - | -| `--open` | - | Open the ERD viewer in the default browser | No | `false` | - | - -See [Global Options](../cli-reference.md#global-options) for options available to all commands. - -#### tailordb erd deploy - -Deploy ERD static website for TailorDB namespace(s). - -**Usage** - -``` -tailor-sdk tailordb erd deploy [options] -``` - -**Options** - -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--namespace ` | `-n` | TailorDB namespace name (optional - deploys all namespaces with erdSite if omitted) | No | - | - | - -See [Global Options](../cli-reference.md#global-options) for options available to all commands. - -**Notes:** - -- ERD commands build from the local TailorDB schema, including plugin-generated TailorDB types. -- `tailordb erd export` writes a self-contained `index.html` viewer to `//dist`. -- `tailordb erd diff` compares exported self-contained viewer HTML files and writes a visual ERD viewer that can switch between the current schema and the highlighted diff. -- `tailordb erd serve` watches the config file and TailorDB type files, then reloads the browser viewer when the rebuilt `index.html` reports a new embedded schema revision. -- `tailordb erd deploy` still requires `erdSite` in `tailor.config.ts` because it uploads the generated viewer to a configured Static Website. - -**Usage Examples:** +The `tailordb erd` commands (export, diff, serve, deploy) are provided by the `@tailor-platform/sdk-plugin-tailordb-erd` CLI plugin. Install it next to the SDK and keep running `tailor tailordb erd ` as before: ```bash -# Deploy ERD for all namespaces with erdSite configured -tailor-sdk tailordb erd deploy - -# Deploy ERD for a specific namespace -tailor-sdk tailordb erd deploy --namespace myNamespace - -# Deploy ERD with JSON output -tailor-sdk tailordb erd deploy --json +npm install -D @tailor-platform/sdk-plugin-tailordb-erd@next +tailor tailordb erd export --namespace myNamespace ``` -**Notes:** - -- This command is a beta feature and may introduce breaking changes in future releases -- Requires `erdSite` to be configured in `tailor.config.ts` for each namespace you want to deploy -- Example config: - ```typescript - export default defineConfig({ - db: { - myNamespace: { - // ... table definitions - erdSite: "my-erd-site-name", - }, - }, - }); - ``` +See the plugin's README for the full command reference. diff --git a/packages/sdk/docs/cli/tailordb.template.md b/packages/sdk/docs/cli/tailordb.template.md index b709353e5..7ac0798a6 100644 --- a/packages/sdk/docs/cli/tailordb.template.md +++ b/packages/sdk/docs/cli/tailordb.template.md @@ -25,19 +25,19 @@ Commands for managing TailorDB tables, data, and schema migrations. ```bash # Truncate all tables in all namespaces (requires confirmation) -tailor-sdk tailordb truncate --all +tailor tailordb truncate --all # Truncate all tables in all namespaces (skip confirmation) -tailor-sdk tailordb truncate --all --yes +tailor tailordb truncate --all --yes # Truncate all tables in a specific namespace -tailor-sdk tailordb truncate --namespace myNamespace +tailor tailordb truncate --namespace myNamespace # Truncate specific types (namespace is auto-detected) -tailor-sdk tailordb truncate User Post Comment +tailor tailordb truncate User Post Comment # Truncate specific types with confirmation skipped -tailor-sdk tailordb truncate User Post --yes +tailor tailordb truncate User Post --yes ``` **Notes:** @@ -55,7 +55,7 @@ tailor-sdk tailordb truncate User Post --yes {{politty:command:tailordb migration:description}} -Note: Migration scripts are automatically executed during `tailor-sdk deploy`. See [Automatic Migration Execution](../services/tailordb-migration.md#automatic-migration-execution) for details. +Note: Migration scripts are automatically executed during `tailor deploy`. See [Automatic Migration Execution](../services/tailordb-migration.md#automatic-migration-execution) for details. {{politty:command:tailordb migration:usage}} @@ -70,54 +70,13 @@ Note: Migration scripts are automatically executed during `tailor-sdk deploy`. S **See also:** For migration concepts, configuration, workflow, and troubleshooting, see the [TailorDB Migrations guide](../services/tailordb-migration.md). -{{politty:command:tailordb erd:heading}} +### tailordb erd -{{politty:command:tailordb erd:description}} - -{{politty:command:tailordb erd:usage}} - -{{politty:command:tailordb erd:global-options-link}} - -{{politty:command:tailordb erd:subcommands}} - -{{politty:command:tailordb erd export}} -{{politty:command:tailordb erd diff}} -{{politty:command:tailordb erd serve}} -{{politty:command:tailordb erd deploy}} - -**Notes:** - -- ERD commands build from the local TailorDB schema, including plugin-generated TailorDB types. -- `tailordb erd export` writes a self-contained `index.html` viewer to `//dist`. -- `tailordb erd diff` compares exported self-contained viewer HTML files and writes a visual ERD viewer that can switch between the current schema and the highlighted diff. -- `tailordb erd serve` watches the config file and TailorDB type files, then reloads the browser viewer when the rebuilt `index.html` reports a new embedded schema revision. -- `tailordb erd deploy` still requires `erdSite` in `tailor.config.ts` because it uploads the generated viewer to a configured Static Website. - -**Usage Examples:** +The `tailordb erd` commands (export, diff, serve, deploy) are provided by the `@tailor-platform/sdk-plugin-tailordb-erd` CLI plugin. Install it next to the SDK and keep running `tailor tailordb erd ` as before: ```bash -# Deploy ERD for all namespaces with erdSite configured -tailor-sdk tailordb erd deploy - -# Deploy ERD for a specific namespace -tailor-sdk tailordb erd deploy --namespace myNamespace - -# Deploy ERD with JSON output -tailor-sdk tailordb erd deploy --json +npm install -D @tailor-platform/sdk-plugin-tailordb-erd@next +tailor tailordb erd export --namespace myNamespace ``` -**Notes:** - -- This command is a beta feature and may introduce breaking changes in future releases -- Requires `erdSite` to be configured in `tailor.config.ts` for each namespace you want to deploy -- Example config: - ```typescript - export default defineConfig({ - db: { - myNamespace: { - // ... table definitions - erdSite: "my-erd-site-name", - }, - }, - }); - ``` +See the plugin's README for the full command reference. diff --git a/packages/sdk/docs/cli/upgrade.md b/packages/sdk/docs/cli/upgrade.md index 4485bfd2c..bb1889c93 100644 --- a/packages/sdk/docs/cli/upgrade.md +++ b/packages/sdk/docs/cli/upgrade.md @@ -5,7 +5,7 @@ Run codemods to upgrade your project to a newer SDK version. **Usage** ``` -tailor-sdk upgrade [options] +tailor upgrade [options] ``` **Options** @@ -25,7 +25,7 @@ The `upgrade` command runs codemods that automatically transform your project co **Typical workflow:** 1. Update your SDK packages to the new version (e.g., `pnpm update @tailor-platform/sdk`) -2. Run `tailor-sdk upgrade --from ` to apply codemods +2. Run `tailor upgrade --from ` to apply codemods 3. Review changes and commit Use `--dry-run` to preview what changes will be made before applying them. diff --git a/packages/sdk/docs/cli/upgrade.template.md b/packages/sdk/docs/cli/upgrade.template.md index 592192f2f..b36976a24 100644 --- a/packages/sdk/docs/cli/upgrade.template.md +++ b/packages/sdk/docs/cli/upgrade.template.md @@ -14,7 +14,7 @@ The `upgrade` command runs codemods that automatically transform your project co **Typical workflow:** 1. Update your SDK packages to the new version (e.g., `pnpm update @tailor-platform/sdk`) -2. Run `tailor-sdk upgrade --from ` to apply codemods +2. Run `tailor upgrade --from ` to apply codemods 3. Review changes and commit Use `--dry-run` to preview what changes will be made before applying them. diff --git a/packages/sdk/docs/cli/user.md b/packages/sdk/docs/cli/user.md index ad1b78445..ca4ceb11f 100644 --- a/packages/sdk/docs/cli/user.md +++ b/packages/sdk/docs/cli/user.md @@ -9,7 +9,7 @@ Login to Tailor Platform. **Usage** ``` -tailor-sdk login [options] +tailor login [options] ``` **Options** @@ -41,7 +41,7 @@ Logout from Tailor Platform. **Usage** ``` -tailor-sdk logout [options] +tailor logout [options] ``` **Options** @@ -52,6 +52,42 @@ tailor-sdk logout [options] See [Global Options](../cli-reference.md#global-options) for options available to all commands. +## auth + +Authentication helpers for scripts and plugins. + +**Usage** + +``` +tailor auth +``` + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + +**Commands** + +| Command | Description | +| --------------------------- | ------------------------------------------------------------------------------------- | +| [`auth token`](#auth-token) | Print a valid Tailor Platform access token to stdout, refreshing it first if expired. | + +### auth token + +Print a valid Tailor Platform access token to stdout, refreshing it first if expired. + +**Usage** + +``` +tailor auth token [options] +``` + +**Options** + +| Option | Alias | Description | Required | Default | Env | +| --------------------- | ----- | ----------------- | -------- | ------- | ------------------------- | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + ## user Manage Tailor Platform users. @@ -59,7 +95,7 @@ Manage Tailor Platform users. **Usage** ``` -tailor-sdk user [command] +tailor user [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -80,7 +116,7 @@ Show current user. **Usage** ``` -tailor-sdk user current +tailor user current ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -92,7 +128,7 @@ List all users. **Usage** ``` -tailor-sdk user list +tailor user list ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -104,7 +140,7 @@ Manage personal access tokens. **Usage** ``` -tailor-sdk user pat [command] +tailor user pat [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -125,7 +161,7 @@ Create a new personal access token. **Usage** ``` -tailor-sdk user pat create [options] +tailor user pat create [options] ``` **Arguments** @@ -149,7 +185,7 @@ Delete a personal access token. **Usage** ``` -tailor-sdk user pat delete +tailor user pat delete ``` **Arguments** @@ -167,7 +203,7 @@ List all personal access tokens. **Usage** ``` -tailor-sdk user pat list [options] +tailor user pat list [options] ``` **Options** @@ -186,7 +222,7 @@ Update a personal access token (delete and recreate). **Usage** ``` -tailor-sdk user pat update [options] +tailor user pat update [options] ``` **Arguments** @@ -210,14 +246,14 @@ Set current user. **Usage** ``` -tailor-sdk user switch +tailor user switch ``` **Arguments** -| Argument | Description | Required | -| -------- | ----------- | -------- | -| `user` | User email | Yes | +| Argument | Description | Required | +| -------- | -------------------------------------------- | -------- | +| `user` | User email address or machine user client ID | Yes | See [Global Options](../cli-reference.md#global-options) for options available to all commands. When no subcommand is provided, defaults to `list`. diff --git a/packages/sdk/docs/cli/user.template.md b/packages/sdk/docs/cli/user.template.md index a78a0ffbd..2530b03ff 100644 --- a/packages/sdk/docs/cli/user.template.md +++ b/packages/sdk/docs/cli/user.template.md @@ -11,6 +11,7 @@ Commands for authentication and user management. {{politty:command:login}} {{politty:command:logout}} +{{politty:command:auth}} {{politty:command:user}} When no subcommand is provided, defaults to `list`. diff --git a/packages/sdk/docs/cli/workflow.md b/packages/sdk/docs/cli/workflow.md index ee6c590f5..38ddd0418 100644 --- a/packages/sdk/docs/cli/workflow.md +++ b/packages/sdk/docs/cli/workflow.md @@ -9,7 +9,7 @@ Manage workflows and workflow executions. **Usage** ``` -tailor-sdk workflow [command] +tailor workflow [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -32,7 +32,7 @@ List all workflows in the workspace. **Usage** ``` -tailor-sdk workflow list [options] +tailor workflow list [options] ``` **Options** @@ -53,7 +53,7 @@ Get workflow details. **Usage** ``` -tailor-sdk workflow get [options] +tailor workflow get [options] ``` **Arguments** @@ -78,7 +78,7 @@ Start a workflow execution. **Usage** ``` -tailor-sdk workflow start [options] +tailor workflow start [options] ``` **Arguments** @@ -93,7 +93,7 @@ tailor-sdk workflow start [options] | ------------------------------- | ----- | --------------------------------------------------------------------------- | -------- | -------------------- | ----------------------------------- | | `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | | `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | | `--machine-user ` | `-m` | Machine user name. Falls back to the active profile's default machine user. | No | - | `TAILOR_PLATFORM_MACHINE_USER_NAME` | | `--arg ` | `-a` | Workflow argument (JSON string) | No | - | - | | `--wait` | `-W` | Wait for execution to complete | No | `false` | - | @@ -108,13 +108,13 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # Start a workflow -tailor-sdk workflow start my-workflow -m admin-machine-user +tailor workflow start my-workflow -m admin-machine-user # Start with argument -tailor-sdk workflow start my-workflow -m admin -a '{"userId": "123"}' +tailor workflow start my-workflow -m admin -a '{"userId": "123"}' # Start and wait for completion -tailor-sdk workflow start my-workflow -m admin -W +tailor workflow start my-workflow -m admin -W ``` ### workflow wait @@ -124,7 +124,7 @@ Wait for a workflow execution. **Usage** ``` -tailor-sdk workflow wait [options] +tailor workflow wait [options] ``` **Arguments** @@ -151,19 +151,19 @@ See [Global Options](../cli-reference.md#global-options) for options available t **Wait for workflow success** ```bash -$ tailor-sdk workflow wait execution-id --until success --timeout 10m --json +$ tailor workflow wait execution-id --until success --timeout 10m --json ``` **Wait for a workflow wait point** ```bash -$ tailor-sdk workflow wait execution-id --until suspended --timeout 6m --logs --json +$ tailor workflow wait execution-id --until suspended --timeout 6m --logs --json ``` **Wait for success, failure, or suspension** ```bash -$ tailor-sdk workflow wait execution-id --until terminal +$ tailor workflow wait execution-id --until terminal ``` **Shell automation** @@ -173,10 +173,10 @@ separate command: ```bash execution_id="$( - tailor-sdk workflow start order-workflow --json | jq -r '.executionId' + tailor workflow start order-workflow --json | jq -r '.executionId' )" -tailor-sdk workflow wait "$execution_id" \ +tailor workflow wait "$execution_id" \ --until success \ --timeout 10m \ --interval 5s \ @@ -186,7 +186,7 @@ tailor-sdk workflow wait "$execution_id" \ Wait until a workflow reaches a wait point, such as an approval step: ```bash -tailor-sdk workflow wait "$execution_id" \ +tailor workflow wait "$execution_id" \ --until suspended \ --timeout 6m \ --logs \ @@ -226,7 +226,7 @@ List or get workflow executions. **Usage** ``` -tailor-sdk workflow executions [options] [execution-id] +tailor workflow executions [options] [execution-id] ``` **Arguments** @@ -257,22 +257,22 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # List all executions -tailor-sdk workflow executions +tailor workflow executions # Filter by workflow name -tailor-sdk workflow executions -n my-workflow +tailor workflow executions -n my-workflow # Filter by status -tailor-sdk workflow executions -s RUNNING +tailor workflow executions -s RUNNING # Get execution details -tailor-sdk workflow executions +tailor workflow executions # Get execution details with logs -tailor-sdk workflow executions --logs +tailor workflow executions --logs # Wait for execution to complete -tailor-sdk workflow executions -W +tailor workflow executions -W ``` ### workflow resume @@ -282,7 +282,7 @@ Resume a failed or pending workflow execution. **Usage** ``` -tailor-sdk workflow resume [options] +tailor workflow resume [options] ``` **Arguments** diff --git a/packages/sdk/docs/cli/workflow.template.md b/packages/sdk/docs/cli/workflow.template.md index 01001d71f..e9981a7cc 100644 --- a/packages/sdk/docs/cli/workflow.template.md +++ b/packages/sdk/docs/cli/workflow.template.md @@ -27,13 +27,13 @@ Commands for managing workflows and workflow executions. ```bash # Start a workflow -tailor-sdk workflow start my-workflow -m admin-machine-user +tailor workflow start my-workflow -m admin-machine-user # Start with argument -tailor-sdk workflow start my-workflow -m admin -a '{"userId": "123"}' +tailor workflow start my-workflow -m admin -a '{"userId": "123"}' # Start and wait for completion -tailor-sdk workflow start my-workflow -m admin -W +tailor workflow start my-workflow -m admin -W ``` {{politty:command:workflow wait}} @@ -45,10 +45,10 @@ separate command: ```bash execution_id="$( - tailor-sdk workflow start order-workflow --json | jq -r '.executionId' + tailor workflow start order-workflow --json | jq -r '.executionId' )" -tailor-sdk workflow wait "$execution_id" \ +tailor workflow wait "$execution_id" \ --until success \ --timeout 10m \ --interval 5s \ @@ -58,7 +58,7 @@ tailor-sdk workflow wait "$execution_id" \ Wait until a workflow reaches a wait point, such as an approval step: ```bash -tailor-sdk workflow wait "$execution_id" \ +tailor workflow wait "$execution_id" \ --until suspended \ --timeout 6m \ --logs \ @@ -97,22 +97,22 @@ if (result.timedOut) { ```bash # List all executions -tailor-sdk workflow executions +tailor workflow executions # Filter by workflow name -tailor-sdk workflow executions -n my-workflow +tailor workflow executions -n my-workflow # Filter by status -tailor-sdk workflow executions -s RUNNING +tailor workflow executions -s RUNNING # Get execution details -tailor-sdk workflow executions +tailor workflow executions # Get execution details with logs -tailor-sdk workflow executions --logs +tailor workflow executions --logs # Wait for execution to complete -tailor-sdk workflow executions -W +tailor workflow executions -W ``` {{politty:command:workflow resume}} diff --git a/packages/sdk/docs/cli/workspace.md b/packages/sdk/docs/cli/workspace.md index f56ea16ea..37236f325 100644 --- a/packages/sdk/docs/cli/workspace.md +++ b/packages/sdk/docs/cli/workspace.md @@ -9,7 +9,7 @@ Manage Tailor Platform workspaces. **Usage** ``` -tailor-sdk workspace [command] +tailor workspace [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -33,7 +33,7 @@ Manage workspace applications **Usage** ``` -tailor-sdk workspace app [command] +tailor workspace app [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -52,7 +52,7 @@ Check application schema health **Usage** ``` -tailor-sdk workspace app health [options] +tailor workspace app health [options] ``` **Options** @@ -72,7 +72,7 @@ List applications in a workspace **Usage** ``` -tailor-sdk workspace app list [options] +tailor workspace app list [options] ``` **Options** @@ -93,7 +93,7 @@ Create a new Tailor Platform workspace. **Usage** ``` -tailor-sdk workspace create [options] +tailor workspace create [options] ``` **Options** @@ -107,7 +107,7 @@ tailor-sdk workspace create [options] | `--folder-id ` | `-f` | Folder ID to workspace associate with | No | - | `TAILOR_PLATFORM_FOLDER_ID` | | `--profile-name ` | `-p` | Profile name to create | No | - | - | | `--profile ` | - | Workspace profile used for authentication and Platform selection | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--profile-user ` | - | User email for the profile (defaults to current user) | No | - | - | +| `--profile-user ` | - | User email address or machine user client ID for the profile (defaults to current user) | No | - | - | | `--permission ` | - | Profile permission (requires --profile-name). 'read' blocks all write commands while the profile is active. | No | `"write"` | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -119,7 +119,7 @@ Delete a Tailor Platform workspace. **Usage** ``` -tailor-sdk workspace delete [options] +tailor workspace delete [options] ``` **Options** @@ -138,7 +138,7 @@ Show detailed information about a workspace **Usage** ``` -tailor-sdk workspace get [options] +tailor workspace get [options] ``` **Options** @@ -157,7 +157,7 @@ List all Tailor Platform workspaces. **Usage** ``` -tailor-sdk workspace list [options] +tailor workspace list [options] ``` **Options** @@ -177,7 +177,7 @@ Restore a deleted workspace **Usage** ``` -tailor-sdk workspace restore [options] +tailor workspace restore [options] ``` **Options** @@ -196,7 +196,7 @@ Manage workspace users **Usage** ``` -tailor-sdk workspace user [command] +tailor workspace user [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -217,7 +217,7 @@ Invite a user to a workspace **Usage** ``` -tailor-sdk workspace user invite [options] +tailor workspace user invite [options] ``` **Options** @@ -238,7 +238,7 @@ List users in a workspace **Usage** ``` -tailor-sdk workspace user list [options] +tailor workspace user list [options] ``` **Options** @@ -259,7 +259,7 @@ Remove a user from a workspace **Usage** ``` -tailor-sdk workspace user remove [options] +tailor workspace user remove [options] ``` **Options** @@ -280,7 +280,7 @@ Update a user's role in a workspace **Usage** ``` -tailor-sdk workspace user update [options] +tailor workspace user update [options] ``` **Options** @@ -301,7 +301,7 @@ Manage workspace profiles (user + workspace combinations). **Usage** ``` -tailor-sdk profile [command] +tailor profile [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -322,7 +322,7 @@ Create a new profile. **Usage** ``` -tailor-sdk profile create [options] +tailor profile create [options] ``` **Arguments** @@ -335,7 +335,7 @@ tailor-sdk profile create [options] | Option | Alias | Description | Required | Default | Env | | ------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------- | ---------------------------------- | -| `--user ` | `-u` | User email | Yes | - | - | +| `--user ` | `-u` | User email address or machine user client ID | Yes | - | - | | `--workspace-id ` | `-w` | Workspace ID | Yes | - | - | | `--permission ` | - | Profile permission. 'read' blocks all write commands while the profile is active. | No | `"write"` | - | | `--machine-user ` | `-m` | Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token). | No | - | - | @@ -353,7 +353,7 @@ Delete a profile. **Usage** ``` -tailor-sdk profile delete +tailor profile delete ``` **Arguments** @@ -371,7 +371,7 @@ List all profiles. **Usage** ``` -tailor-sdk profile list +tailor profile list ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -383,7 +383,7 @@ Update profile properties. **Usage** ``` -tailor-sdk profile update [options] +tailor profile update [options] ``` **Arguments** @@ -396,7 +396,7 @@ tailor-sdk profile update [options] | Option | Alias | Description | Required | Default | | ------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | -| `--user ` | `-u` | New user email | No | - | +| `--user ` | `-u` | New user email address or machine user client ID | No | - | | `--workspace-id ` | `-w` | New workspace ID | No | - | | `--permission ` | - | Profile permission. 'read' blocks all write commands; 'write' lifts the restriction. | No | - | | `--machine-user ` | `-m` | Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token). Pass an empty string to clear. | No | - | diff --git a/packages/sdk/docs/configuration.md b/packages/sdk/docs/configuration.md index f21318e7d..72d668222 100644 --- a/packages/sdk/docs/configuration.md +++ b/packages/sdk/docs/configuration.md @@ -27,7 +27,7 @@ export default defineConfig({ cors: ["https://example.com"], allowedIpAddresses: ["192.168.1.0/24"], disableIntrospection: false, - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", }); ``` @@ -46,11 +46,11 @@ export default defineConfig({ ```typescript export default defineConfig({ name: "my-app", - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", }); ``` -This is a bundle-time setting. Changing `LOG_LEVEL` affects newly bundled deployments; already deployed functions must be redeployed. +This is a bundle-time setting. Changing `TAILOR_APP_LOG_LEVEL` affects newly bundled deployments; already deployed functions must be redeployed. ### Service Configuration @@ -326,5 +326,3 @@ export const plugins = definePlugins( enumConstantsPlugin({ distPath: "./generated/enums.ts" }), ); ``` - -See [Generators](./generator/index.md) for legacy `defineGenerators()` documentation. diff --git a/packages/sdk/docs/generator/builtin.md b/packages/sdk/docs/generator/builtin.md deleted file mode 100644 index d961934df..000000000 --- a/packages/sdk/docs/generator/builtin.md +++ /dev/null @@ -1,257 +0,0 @@ -# Builtin Generators - -The SDK includes four builtin generators for common code generation tasks. - -## @tailor-platform/kysely-type - -Generates Kysely type definitions and the `getDB()` function for type-safe database access. - -### Configuration - -```typescript -["@tailor-platform/kysely-type", { distPath: "./generated/tailordb.ts" }]; -``` - -| Option | Type | Description | -| ---------- | -------- | --------------------------- | -| `distPath` | `string` | Output file path (required) | - -### Output - -Generates a TypeScript file containing: - -- Type definitions for all TailorDB types -- `getDB(namespace)` function to create Kysely instances -- Utility types for `Timestamp`, `Serial`, and `ObjectColumnType` (used for nested object fields so insert and select types stay correct) - -### Usage - -```typescript -import { getDB } from "./generated/tailordb"; - -// In resolvers -body: async (context) => { - const db = getDB("tailordb"); - const users = await db - .selectFrom("User") - .selectAll() - .where("email", "=", context.input.email) - .execute(); - return { users }; -}; - -// In executors -body: async ({ newRecord }) => { - const db = getDB("tailordb"); - await db.insertInto("AuditLog").values({ userId: newRecord.id, action: "created" }).execute(); -}; - -// In workflow jobs -body: async (input, { env }) => { - const db = getDB("tailordb"); - return await db - .selectFrom("Order") - .selectAll() - .where("id", "=", input.orderId) - .executeTakeFirst(); -}; -``` - -### Raw SQL - -For queries that the Kysely query builder can't express, use the `sql` tag re-exported from `@tailor-platform/sdk/kysely`. Plain value substitutions (`${...}`) are sent as bound parameters, so user-supplied values are parameterized safely. SQL fragments produced by Kysely helpers (for example `sql.raw(...)`, identifiers, refs) are inlined into the generated SQL string by design — do not pass untrusted input through those. - -```typescript -import { sql } from "@tailor-platform/sdk/kysely"; -import { getDB } from "./generated/tailordb"; - -createResolver({ - name: "supplierCountByState", - operation: "query", - input: { country: t.string() }, - output: t.object({ - rows: t.array(t.object({ state: t.string(), count: t.int() })), - }), - body: async ({ input }) => { - const db = getDB("tailordb"); - const { rows } = await sql<{ state: string; count: number }>` - SELECT state, COUNT(*) AS count - FROM "Supplier" - WHERE country = ${input.country} - GROUP BY state - `.execute(db); - return { rows }; - }, -}); -``` - -The same `sql` tag works inside `db.transaction().execute(async (trx) => ...)` by passing `trx` to `.execute()`: - -```typescript -await db.transaction().execute(async (trx) => { - await sql`UPDATE "Supplier" SET state = ${state} WHERE id = ${id}`.execute(trx); -}); -``` - -## @tailor-platform/enum-constants - -Extracts enum constants from TailorDB type definitions. - -### Configuration - -```typescript -["@tailor-platform/enum-constants", { distPath: "./generated/enums.ts" }]; -``` - -| Option | Type | Description | -| ---------- | -------- | --------------------------- | -| `distPath` | `string` | Output file path (required) | - -### Output - -Generates TypeScript constants for all enum fields: - -```typescript -// Generated output -export const OrderStatus = { - PENDING: "PENDING", - PROCESSING: "PROCESSING", - COMPLETED: "COMPLETED", - CANCELLED: "CANCELLED", -} as const; - -export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus]; -``` - -### Usage - -```typescript -import { OrderStatus } from "./generated/enums"; - -// Type-safe enum usage -const status: OrderStatus = OrderStatus.PENDING; - -// In queries -const orders = await db - .selectFrom("Order") - .selectAll() - .where("status", "=", OrderStatus.COMPLETED) - .execute(); -``` - -## @tailor-platform/file-utils - -Generates utility functions for handling file-type fields in TailorDB. - -### Configuration - -```typescript -["@tailor-platform/file-utils", { distPath: "./generated/files.ts" }]; -``` - -| Option | Type | Description | -| ---------- | -------- | --------------------------- | -| `distPath` | `string` | Output file path (required) | - -### Output - -Generates TypeScript interfaces and utilities for types with file fields: - -```typescript -// Generated output -export interface UserFileFields { - avatar: string; - documents: string; -} - -export function getUserFileFields(): (keyof UserFileFields)[] { - return ["avatar", "documents"]; -} -``` - -## @tailor-platform/seed - -Generates seed data configuration files for database initialization. - -### Configuration - -```typescript -// Basic configuration -["@tailor-platform/seed", { distPath: "./seed" }]; - -// With default machine user -["@tailor-platform/seed", { distPath: "./seed", machineUserName: "admin" }]; -``` - -| Option | Type | Description | -| -------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `distPath` | `string` | Output directory path (required) | -| `machineUserName` | `string` | Default machine user name (can be overridden at runtime) | -| `disableIdpUserSync` | `{ userToIdp?: boolean; idpToUser?: boolean }` | Skip emitting individual `_User <-> userProfile` foreign keys. Both directions are emitted by default. See [IdP user synchronization](#idp-user-synchronization). | - -### IdP user synchronization - -When `auth.userProfile` is configured, the seed plugin treats the userProfile -type (e.g. `User`) and the IdP-managed `_User` table as a pair. By default it -emits foreign keys in both directions so that `validate` rejects any row in -either table that does not have a matching counterpart: - -| Direction | Foreign key | Catches | -| ----------- | ---------------------------------------------- | ---------------------------------------------- | -| `idpToUser` | `_User.name` → `.` | Creating an IdP credential with no profile row | -| `userToIdp` | `.` → `_User.name` | Creating a profile row with no IdP credential | - -Neither direction is enforced by the runtime. In production it is normal for -one side to exist without the other — for example a userProfile row exists -the moment a user is invited, but the corresponding `_User` row appears only -after the user finishes signing up. To seed such states, set the relevant -direction in `disableIdpUserSync` to `true`: - -```typescript -// Allow seeding invited-but-not-registered userProfile rows. -// Still rejects _User rows without a matching userProfile row. -["@tailor-platform/seed", { distPath: "./seed", disableIdpUserSync: { userToIdp: true } }]; - -// Allow seeding _User rows whose userProfile does not exist yet. -// Still rejects userProfile rows without a matching _User row. -["@tailor-platform/seed", { distPath: "./seed", disableIdpUserSync: { idpToUser: true } }]; -``` - -Omitted directions default to `false` (FK emitted). - -### Output - -Generates a seed directory structure: - -``` -seed/ -├── data/ -│ ├── User.jsonl # Seed data files (JSONL format) -│ ├── User.schema.ts # lines-db schema definitions -│ └── Product.jsonl -└── exec.mjs # Executable script -``` - -### Usage - -Run the generated executable script: - -```bash -# With machine user from config -node seed/exec.mjs - -# Specify machine user at runtime (required if not configured, or to override) -node seed/exec.mjs --machine-user admin - -# Short form -node seed/exec.mjs -m admin - -# With other options -node seed/exec.mjs -m admin --truncate --yes -``` - -The `--machine-user` option is required at runtime if `machineUserName` is not configured in the generator options. - -The generated files are compatible with gql-ingest for bulk data import. - -The `exec.mjs` is fully regenerated on every `sdk generate` and starts with an `@generated` header — do not hand-edit it. Its `--truncate` path reuses the `tailordb truncate` command, so namespaces declared with `{ external: true }` are skipped automatically and a shell app cannot wipe a sibling app's data via `seed:reset`. diff --git a/packages/sdk/docs/generator/custom.md b/packages/sdk/docs/generator/custom.md deleted file mode 100644 index 5272636d3..000000000 --- a/packages/sdk/docs/generator/custom.md +++ /dev/null @@ -1,147 +0,0 @@ -# Custom Generators (Deprecated) - -> **Deprecated**: Use `definePlugins()` with generation-time hooks (`onTypeLoaded`, `generate`, etc.) instead. See [Custom Plugins](../plugin/custom.md) for the recommended approach. - -Create your own generators by implementing the `CodeGenerator` interface. - -## CodeGenerator Interface - -```typescript -interface CodeGenerator { - id: string; - description: string; - - // Process individual items - processType(args: { - type: TailorDBType; - namespace: string; - source: { filePath: string; exportName: string }; - }): T | Promise; - - processResolver(args: { resolver: Resolver; namespace: string }): R | Promise; - - processExecutor(executor: Executor): E | Promise; - - // Aggregate per namespace (optional) - processTailorDBNamespace?(args: { - namespace: string; - types: Record; - }): Ts | Promise; - - processResolverNamespace?(args: { - namespace: string; - resolvers: Record; - }): Rs | Promise; - - // Final aggregation - aggregate(args: { - input: GeneratorInput; - executorInputs: E[]; - baseDir: string; - }): GeneratorResult | Promise; -} -``` - -## GeneratorResult - -Generators return a `GeneratorResult` containing files to create: - -```typescript -interface GeneratorResult { - files: Array<{ - path: string; // Relative path from project root - content: string; // File content - skipIfExists?: boolean; // Skip if file already exists (default: false) - executable?: boolean; // Make file executable (default: false) - }>; - errors?: string[]; -} -``` - -## Example: Simple Type List Generator - -```typescript -import type { CodeGenerator, GeneratorResult } from "@tailor-platform/sdk"; - -const typeListGenerator: CodeGenerator = { - id: "type-list", - description: "Generates a list of all TailorDB type names", - - processType({ type }) { - return type.name; - }, - - processResolver() { - return null; - }, - - processExecutor() { - return null; - }, - - processTailorDBNamespace({ types }) { - return Object.values(types); - }, - - aggregate({ input }) { - const allTypes = input.tailordb.flatMap((ns) => ns.types); - const content = `// Generated type list\nexport const types = ${JSON.stringify(allTypes, null, 2)} as const;\n`; - - return { - files: [{ path: "generated/types.ts", content }], - }; - }, -}; -``` - -## Using Custom Generators - -Pass the generator object directly to `defineGenerators()`: - -```typescript -import { defineGenerators } from "@tailor-platform/sdk"; -import { typeListGenerator } from "./generators/type-list"; - -export const generators = defineGenerators( - ["@tailor-platform/kysely-type", { distPath: "./generated/tailordb.ts" }], - typeListGenerator, // Custom generator -); -``` - -## Available Input Data - -### TailorDBType - -Contains full type information including: - -- `name`: Type name -- `fields`: Field definitions with types, validation, and descriptions -- `relations`: Relationship definitions -- `indexes`: Index configurations -- `permission`: Permission rules - -### Resolver - -Contains resolver configuration: - -- `name`: Resolver name -- `operation`: Query or mutation -- `input`: Input schema -- `output`: Output schema - -### Executor - -Contains executor configuration: - -- `name`: Executor name -- `trigger`: Trigger configuration -- `operation`: Execution target - -### GeneratorAuthInput - -Contains authentication configuration when available: - -- `name`: Auth service name -- `userProfile`: User profile type information -- `machineUsers`: Machine user definitions -- `oauth2Clients`: OAuth2 client configurations diff --git a/packages/sdk/docs/generator/index.md b/packages/sdk/docs/generator/index.md deleted file mode 100644 index 25882e41b..000000000 --- a/packages/sdk/docs/generator/index.md +++ /dev/null @@ -1,66 +0,0 @@ -# Generators - -Generators analyze your TailorDB types, Resolvers, and Executors to automatically generate TypeScript code. - -## Overview - -When you run `tailor-sdk generate`, the SDK: - -1. Loads all TailorDB types, Resolvers, and Executors from your configuration -2. Passes each definition to the configured generators -3. Aggregates the results and writes output files - -This enables generators to create derived code based on your application's schema. For example, the `@tailor-platform/kysely-type` generator produces type-safe database access code from your TailorDB definitions. - -## Configuration - -Define generators in `tailor.config.ts` using `defineGenerators()`: - -```typescript -import { defineConfig, defineGenerators } from "@tailor-platform/sdk"; - -export const generators = defineGenerators( - ["@tailor-platform/kysely-type", { distPath: "./generated/tailordb.ts" }], - ["@tailor-platform/enum-constants", { distPath: "./generated/enums.ts" }], -); - -export default defineConfig({ - name: "my-app", - // ... -}); -``` - -**Important**: The `generators` export must be a named export (not default). - -## CLI Commands - -### Generate Files - -```bash -tailor-sdk generate -``` - -Generates all configured output files. - -### Watch Mode - -```bash -tailor-sdk generate --watch -``` - -Watches for file changes and regenerates automatically. - -## Environment Variables - -### `TAILOR_PLATFORM_SDK_DTS_PATH` - -Customize the output path of the generated `tailor.d.ts` type definition file. By default, `tailor.d.ts` is written next to `tailor.config.ts`. - -```bash -TAILOR_PLATFORM_SDK_DTS_PATH=src/types/tailor.d.ts tailor-sdk generate -``` - -## Generator Types - -- [Builtin Generators](./builtin.md) - Ready-to-use generators included with the SDK -- [Custom Generators](./custom.md) - Create your own generators (Preview) diff --git a/packages/sdk/docs/github-actions.md b/packages/sdk/docs/github-actions.md index 4b765b4df..8b6bb968c 100644 --- a/packages/sdk/docs/github-actions.md +++ b/packages/sdk/docs/github-actions.md @@ -1,10 +1,10 @@ # GitHub Actions Integration -`tailor-sdk setup` generates a GitHub Actions workflow that deploys your +`tailor setup` generates a GitHub Actions workflow that deploys your Tailor Platform application automatically on push or tag. > **Beta:** This command is under active development. CLI flags, the generated -> workflow, and the `.github/tailor-sdk.lock` schema may change before general +> workflow, and the `.github/tailor.lock` schema may change before general > availability. ## Quick start @@ -14,10 +14,10 @@ lives): ```bash # Branch target: deploy to stg on every push to main -tailor-sdk setup -n my-app-stg +tailor setup -n my-app-stg # Tag target: deploy to production when a tag is pushed, with an approval gate -tailor-sdk setup -n my-app-prod \ +tailor setup -n my-app-prod \ --tag --branch main --environment production ``` @@ -44,9 +44,9 @@ The branch target fires on pull requests and pushes to the branch you specify (defaulting to the repository's default branch when `--branch` is omitted): ```bash -tailor-sdk setup -n my-app-stg +tailor setup -n my-app-stg # Equivalent to: -tailor-sdk setup -n my-app-stg --branch main +tailor setup -n my-app-stg --branch main ``` What it does: @@ -69,7 +69,15 @@ Pass `--erd-preview` on a branch target to add TailorDB ERD preview artifacts to pull requests: ```bash -tailor-sdk setup -n my-app-stg --erd-preview +tailor setup -n my-app-stg --erd-preview +``` + +The generated workflow runs `tailor tailordb erd`, which is provided by the +`@tailor-platform/sdk-plugin-tailordb-erd` CLI plugin — install it as a +dev-dependency in your project: + +```bash +npm install -D @tailor-platform/sdk-plugin-tailordb-erd@next ``` The generated workflow builds one self-contained ERD viewer HTML file for each @@ -96,7 +104,7 @@ The tag target fires when a tag matching `--tag-pattern` (default `v*`) is pushed: ```bash -tailor-sdk setup -n my-app-prod \ +tailor setup -n my-app-prod \ --tag --tag-pattern "v*" --branch main --environment production ``` @@ -145,7 +153,7 @@ Because the variable is scoped to a GitHub Environment, both the `plan` and this is a manual step: ```bash - tailor-sdk workspace create # copy the printed workspace id + tailor workspace create # copy the printed workspace id ``` 2. Set the id as the Environment variable (the environment name is your @@ -188,7 +196,7 @@ step or added your own — the command stops and reports the conflict. Pass re-apply your own steps. (Preserving user-added steps across regeneration is planned.) -### `.github/tailor-sdk.lock` +### `.github/tailor.lock` A machine-owned JSON file that tracks which files the SDK manages, the inputs they were generated from, and their content hashes. **Commit this file. Never @@ -198,14 +206,14 @@ detect hand edits. ### `tailor.config.ts` (id injection) If your config does not already have an `id` field, `setup` injects one. -This `id` must be committed alongside the workflow file. In CI, `tailor-sdk +This `id` must be committed alongside the workflow file. In CI, `tailor deploy` refuses to inject a new id — if the id were assigned fresh on each CI run, every deploy would create a brand-new application and lose ownership of previously deployed resources. If your pipeline intentionally deploys a fresh, throwaway application on every run (for example an end-to-end test harness that creates and deletes its own -workspace), set `TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION=true` to opt back +workspace), set `TAILOR_CI_ALLOW_ID_INJECTION=true` to opt back into automatic id injection for that pipeline. ## Secrets @@ -268,7 +276,7 @@ you can deploy any commit regardless of branch membership. For a monorepo where your SDK app lives in a subdirectory, pass `--dir`: ```bash -tailor-sdk setup -n my-app --dir apps/backend +tailor setup -n my-app --dir apps/backend ``` The generated workflow adds a `paths` filter on `apps/backend/**` so the @@ -277,7 +285,7 @@ SDK commands is set accordingly. ## Rollback -`tailor-sdk deploy` is declarative: redeploying a past configuration returns +`tailor deploy` is declarative: redeploying a past configuration returns the platform to that state. The recommended rollback approaches are: ### Option 1 — Revert the commit (branch target) @@ -333,10 +341,10 @@ A typical setup with staging and production: ```bash # Staging: main → stg (deploy on every push to main) -tailor-sdk setup -n my-app-stg +tailor setup -n my-app-stg # Production: tagged commits → prod, with approval gate and branch guard -tailor-sdk setup -n my-app-prod \ +tailor setup -n my-app-prod \ --tag --branch main --environment production ``` @@ -354,12 +362,12 @@ gh secret set TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID --env production gh secret set TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET --env production ``` -Commit both workflow files and `.github/tailor-sdk.lock`. +Commit both workflow files and `.github/tailor.lock`. ## Checking for drift -`tailor-sdk setup check` audits the workflows recorded in -`.github/tailor-sdk.lock` against your current config and repository, without +`tailor setup check` audits the workflows recorded in +`.github/tailor.lock` against your current config and repository, without writing anything. It reports when a workflow file is missing or hand-edited, a newer template is available, `tailor.config.ts` is no longer under the recorded `--dir`, or the repository default branch no longer matches a branch target's @@ -373,5 +381,5 @@ template improvements. If the SDK detects that you have hand-edited a managed section, it stops and asks you to use `--force` to overwrite your edits, or to move your customizations into your own steps before regenerating. -The `.github/tailor-sdk.lock` file records the flags used at generation time, +The `.github/tailor.lock` file records the flags used at generation time, so you can check what arguments were used previously. diff --git a/packages/sdk/docs/migration/v2.md b/packages/sdk/docs/migration/v2.md new file mode 100644 index 000000000..2c50bf312 --- /dev/null +++ b/packages/sdk/docs/migration/v2.md @@ -0,0 +1,1210 @@ +# Migrating to v2 + + + +Run the codemods, then finish anything reported as not migrated automatically: + +```sh +npx @tailor-platform/sdk-codemod --from --to +``` + +## defineGenerators → definePlugins + +**Migration:** Partially automatic + +Migrate defineGenerators() tuple syntax to definePlugins() with explicit plugin imports + +Before: + +```ts +import { defineGenerators } from "@tailor-platform/sdk"; + +export const generators = defineGenerators( + ["@tailor-platform/kysely-type", { distPath: "db.ts" }], +); +``` + +After: + +```ts +import { definePlugins } from "@tailor-platform/sdk"; +import { kyselyTypePlugin } from "@tailor-platform/sdk/plugin/kysely-type"; + +export const generators = definePlugins(kyselyTypePlugin({ distPath: "db.ts" })); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +defineGenerators() is replaced by definePlugins() in v2. The codemod rewrites the +known plugin tuples (kysely-type, enum-constants, file-utils, seed). For any +remaining defineGenerators([...]) the codemod left in place — a plugin it does not +know, or a non-tuple/spread form — convert it to definePlugins(pluginFn(config)), +importing the matching plugin from its @tailor-platform/sdk/plugin/ subpath. +``` + +
+ +## @tailor-platform/sdk/cli plugin imports → dedicated subpaths + +**Migration:** Automatic + +Rewrite deprecated plugin re-export imports (kyselyTypePlugin, enumConstantsPlugin, fileUtilsPlugin, seedPlugin) from `@tailor-platform/sdk/cli` to their dedicated plugin subpaths + +Before: + +```ts +import { kyselyTypePlugin } from "@tailor-platform/sdk/cli"; +``` + +After: + +```ts +import { kyselyTypePlugin } from "@tailor-platform/sdk/plugin/kysely-type"; +``` + +## function test-run --arg input unwrap + +**Migration:** Automatic + +Strip the deprecated {input: ...} wrapper from `tailor function test-run --arg` JSON in scripts and docs + +Before: + +```sh +tailor function test-run resolvers/add.ts --arg '{"input":{"a":1}}' +``` + +After: + +```sh +tailor function test-run resolvers/add.ts --arg '{"a":1}' +``` + +## tailor-sdk-skills → tailor skills add + +**Migration:** Partially automatic + +Replace deprecated `tailor-sdk-skills` invocations with `tailor skills add` + +Before: + +```sh +npx tailor-sdk-skills +``` + +After: + +```sh +tailor skills add +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +The standalone tailor-sdk-skills binary is removed in v2; call the skills add +subcommand on the main tailor CLI instead. Replace any remaining +tailor-sdk-skills invocations the codemod did not rewrite with +`tailor skills add`. +``` + +
+ +## Unify TailorUser/TailorActor/TailorActorType/TailorInvoker → TailorPrincipal + +**Migration:** Partially automatic + +Rename TailorUser/TailorActor/TailorActorType/TailorInvoker to TailorPrincipal, drop unauthenticatedTailorUser, rename resolver body `user` to `caller`, and rename TailorDB callback `user` to `invoker` + +Type references unify under `TailorPrincipal`: + +Before: + +```ts +import type { TailorUser } from "@tailor-platform/sdk"; +``` + +After: + +```ts +import type { TailorPrincipal } from "@tailor-platform/sdk"; +``` + +The resolver body `user` becomes `caller`: + +Before: + +```ts +body: ({ input, user }) => user.id, +``` + +After: + +```ts +body: ({ input, caller }) => caller.id, +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +Finish the cases the codemod left for manual migration: +- Rename user -> caller in resolver bodies the codemod skipped because a `caller` + binding already exists or renaming would shadow/collide with another value. +- Replace member-access on the removed unauthenticatedTailorUser (e.g. + unauthenticatedTailorUser.id); the codemod only replaced standalone references + with null and left member access to surface a type error. +- Review helper adapters that still accept or read `context.user`; v2 resolver + context uses nullable `caller` and `invoker`, so project-specific helper + semantics for anonymous callers and command invokers must be chosen explicitly. +- Review `caller?.` values passed to APIs that require non-null values. If the + resolver requires authentication, throw or otherwise narrow before the call; + if anonymous callers are allowed, keep the nullable flow explicit. +Use TailorPrincipal for the unified user/actor/invoker type. +``` + +
+ +## AttributeMap → Attributes + +**Migration:** Partially automatic + +Rename auth attribute module augmentation and related SDK type names from `AttributeMap` to `Attributes` + +Module augmentation uses `Attributes`: + +Before: + +```ts +declare module "@tailor-platform/sdk" { + interface AttributeMap { + role: string; + } +} +``` + +After: + +```ts +declare module "@tailor-platform/sdk" { + interface Attributes { + role: string; + } +} +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2, the auth attribute type API is renamed from `AttributeMap` +to `Attributes`; related SDK types are renamed to `UserAttributes` and +`InferredAttributes`. The codemod rewrites SDK imports, re-exports, +namespace-qualified references, import() type references, and module +augmentations. Review any remaining matches manually and leave unrelated +local names or deploy/proto wire field names unchanged. +``` + +
+ +## tailor-sdk apply → tailor-sdk deploy + +**Migration:** Automatic + +Rewrite `tailor-sdk apply` invocations in package.json scripts, shell scripts, CI configs, and docs to the canonical v2 `tailor-sdk deploy` command + +Before: + +```sh +tailor-sdk apply --profile prod +``` + +After: + +```sh +tailor-sdk deploy --profile prod +``` + +## v2 CLI rename + +**Migration:** Partially automatic + +Rewrite `tailor-sdk crash-report` to `tailor-sdk crashreport` and `--machineuser` to `--machine-user` across package.json scripts, shell scripts, CI configs, and docs + +Before: + +```sh +tailor-sdk crash-report list +tailor-sdk login --machineuser +``` + +After: + +```sh +tailor-sdk crashreport list +tailor-sdk login --machine-user +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +Apply the v2 CLI renames the codemod did not reach (only `tailor-sdk`-prefixed +invocations are rewritten): `tailor-sdk crash-report` -> `tailor-sdk crashreport` +and the `--machineuser` option -> `--machine-user`. Leave unrelated commands that +happen to use `--machineuser` alone. +``` + +
+ +## SDK environment variable rename + +**Migration:** Partially automatic + +Rewrite unambiguous removed SDK environment variable names to their v2 `TAILOR_*` names and flag generic names for manual review + +Before: + +```sh +TAILOR_PLATFORM_SDK_BUILD_ONLY=true tailor-sdk deploy +``` + +After: + +```sh +TAILOR_DEPLOY_BUILD_ONLY=true tailor-sdk deploy +``` + +Before: + +```ts +const token = process.env.TAILOR_TOKEN; +``` + +After: + +```ts +const token = process.env.TAILOR_PLATFORM_TOKEN; +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +Review any remaining removed SDK environment variable names after the codemod +runs. The codemod intentionally leaves generic names such as `LOG_LEVEL`, +`PLATFORM_URL`, and `PLATFORM_OAUTH2_CLIENT_ID` for manual review because +they can configure non-SDK tools. Replace only actual SDK usages with their +v2 names. If a remaining match is an unrelated local identifier, fixture +label, or historical documentation that intentionally does not configure the +SDK, leave it unchanged. +``` + +
+ +## auth.invoker("name") → "name" + +**Migration:** Partially automatic + +Replace statically identified SDK `auth.invoker("name")` option values with the bare `"name"` string while preserving the `authInvoker` key for SDK versions before the option rename. + +Before: + +```ts +createResolver({ authInvoker: auth.invoker("manager") }); +``` + +After: + +```ts +createResolver({ authInvoker: "manager" }); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2 the auth.invoker() helper is removed; an invoker is now the +machine user name passed directly as a string. The codemod already rewrote the +statically identified SDK option form authInvoker: auth.invoker("name") to authInvoker: "name". These files still contain +auth.invoker(...) calls that need manual review. + +For each remaining auth.invoker() call: +1. Replace the whole call with only where the target option expects a + machine user name string; platform/runtime authInvoker payloads still expect + the object form. +2. Keep the authInvoker key when targeting SDK versions before the invoker + option rename; later v2 targets run a separate codemod for that key rename. +3. After removing every auth.invoker usage in a file, delete the now-unused auth + import (keeping it pulls Node-only config modules into runtime bundles); leave + the import if auth is still referenced elsewhere. + +Do not change behavior beyond the auth.invoker() removal. +``` + +
+ +## auth.invoker("name") → invoker: "name" + +**Migration:** Partially automatic + +Rename statically identified SDK `authInvoker` options to `invoker`, replace `auth.invoker("name")` there with the bare `"name"` string, and drop the `auth` import when no other reference remains. Ambiguous workflow `.start()` calls are left for manual review. The `auth.invoker()` helper is removed in v2 because importing `auth` from `tailor.config.ts` into runtime files pulls Node-only modules into the bundle. + +Before: + +```ts +createResolver({ invoker: auth.invoker("manager") }); +``` + +After: + +```ts +createResolver({ invoker: "manager" }); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2 the auth.invoker() helper is removed; an invoker is now the +machine user name passed directly as a string. The codemod already rewrote the +statically identified SDK option form authInvoker: auth.invoker("name") to invoker: "name" and renamed supported authInvoker option keys. These files still contain +auth.invoker(...) calls or authInvoker keys that need manual review. + +For each remaining auth.invoker() call: +1. Replace the whole call with only where the target option expects a + machine user name string; platform/runtime authInvoker payloads still expect + the object form. +2. Rename remaining authInvoker option keys to invoker only for SDK resolver, + executor, workflow.start(), or startWorkflow() options. Keep platform/runtime + payload keys such as tailor.workflow.startWorkflow(..., { authInvoker: ... }). +3. After removing every auth.invoker usage in a file, delete the now-unused auth + import (keeping it pulls Node-only config modules into runtime bundles); leave + the import if auth is still referenced elsewhere. + +Do not change behavior beyond the SDK option rename and auth.invoker() removal. +``` + +
+ +## auth.getConnectionToken() → runtime authconnection + +**Migration:** Partially automatic + +The deprecated `auth.getConnectionToken()` helper returned by `defineAuth()` is removed in v2. Use `authconnection.getConnectionToken(...)` from `@tailor-platform/sdk/runtime` in resolvers, executors, and workflows instead. + +Before: + +```ts +import { auth } from "../tailor.config"; + +const token = await auth.getConnectionToken("google"); +``` + +After: + +```ts +import { authconnection } from "@tailor-platform/sdk/runtime"; + +const token = await authconnection.getConnectionToken("google"); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2 the auth.getConnectionToken() helper returned by defineAuth() +is removed. Runtime code should call authconnection.getConnectionToken(...) from +@tailor-platform/sdk/runtime instead of importing auth from tailor.config.ts. + +For each getConnectionToken usage where is a defineAuth() result +imported from tailor.config.ts: +1. Replace .getConnectionToken() calls with + authconnection.getConnectionToken(). +2. Update non-call references, including .getConnectionToken, + ["getConnectionToken"], and destructuring from , to + reference authconnection instead. +3. Add or reuse `import { authconnection } from "@tailor-platform/sdk/runtime"`. +4. Remove the auth import from tailor.config.ts only when no other auth reference + remains in the file. + +Leave usages unchanged when the receiver is already the runtime authconnection +wrapper or global tailor.authconnection. +``` + +
+ +## Runtime subpath imports use namespace objects + +**Migration:** Partially automatic + +Rewrite `@tailor-platform/sdk/runtime/*` namespace-star and flat value imports to self-named namespace imports, and aggregate `file.deleteFile` calls to `file.delete`. `TailorContextAPI` and `TailorWorkflowAPI` now describe SDK wrappers; direct platform globals use `PlatformContextAPI` and `PlatformWorkflowAPI`. + +Before: + +```ts +import * as iconv from "@tailor-platform/sdk/runtime/iconv"; +iconv.convert(value, "UTF-8", "Shift_JIS"); +``` + +After: + +```ts +import { iconv } from "@tailor-platform/sdk/runtime/iconv"; +iconv.convert(value, "UTF-8", "Shift_JIS"); +``` + +Before: + +```ts +import { get } from "@tailor-platform/sdk/runtime/aigateway"; +const gateway = await get("main"); +``` + +After: + +```ts +import { aigateway } from "@tailor-platform/sdk/runtime/aigateway"; +const gateway = await aigateway.get("main"); +``` + +Before: + +```ts +import { file } from "@tailor-platform/sdk/runtime"; +await file.deleteFile("ns", "Doc", "blob", "record-id"); +``` + +After: + +```ts +import { file } from "@tailor-platform/sdk/runtime"; +await file.delete("ns", "Doc", "blob", "record-id"); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2, runtime subpath modules export only a self-named namespace +object (for example, `iconv` from `@tailor-platform/sdk/runtime/iconv`). +Default and flat value imports such as +`import { get } from "@tailor-platform/sdk/runtime/aigateway"` are removed. +The codemod rewrites straightforward namespace-star imports and flat named value +imports. It also rewrites direct `file.deleteFile` calls on the aggregate runtime +namespace to `file.delete`. Destructured aggregate `deleteFile` references require +manual migration. Review any remaining runtime imports manually, especially when +a local binding or nested scope shadows an imported value, or when +type-position namespace member references need explicit top-level type imports. +For direct platform globals, replace `TailorContextAPI` and `TailorWorkflowAPI` +type references with `PlatformContextAPI` and `PlatformWorkflowAPI` respectively. +``` + +
+ +## Tailordb → tailordb (lowercase ambient namespace) + +**Migration:** Partially automatic + +Rewrite references to the removed capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, `typeof Tailordb.Client`) to the lowercase `tailordb.*` namespace exposed by `@tailor-platform/sdk/runtime/globals`. Because v2 no longer activates ambient declarations automatically, each file that contains `tailordb.*` references after the rewrite must also add `import "@tailor-platform/sdk/runtime/globals"`. + +Before: + +```ts +const command: Tailordb.CommandType = "SELECT"; +``` + +After: + +```ts +import "@tailor-platform/sdk/runtime/globals"; +const command: tailordb.CommandType = "SELECT"; +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +The capital-cased Tailordb ambient namespace is removed in v2; use the lowercase +tailordb.* namespace from @tailor-platform/sdk/runtime/globals. The codemod rewrites +the known members (QueryResult, CommandType, Client). Rewrite any other remaining +Tailordb.* reference to its tailordb.* equivalent (and confirm the member still +exists on the lowercase namespace). +Also add `import "@tailor-platform/sdk/runtime/globals"` at the top of each file +that contains any tailordb.* type reference — v2 no longer activates ambient +declarations automatically on SDK import. +``` + +
+ +## db.type() → db.table() + +**Migration:** Partially automatic + +Rename TailorDB schema builder calls from `db.type()` to `db.table()`. TailorDB schema definitions now use table terminology in SDK projects. + +Before: + +```ts +import { db } from "@tailor-platform/sdk"; + +export const user = db.type("User", { + name: db.string(), +}); +``` + +After: + +```ts +import { db } from "@tailor-platform/sdk"; + +export const user = db.table("User", { + name: db.string(), +}); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2, TailorDB schema definitions use db.table(...) instead of +db.type(...). The codemod rewrites member accesses on db imported from +@tailor-platform/sdk, including aliases such as `import { db as schema }`. +It flags destructured builder aliases such as `const { type } = db` and +local builder aliases such as `const schema = db`, `schema = db`, or +`function make(schema = db) { ... }` for manual review because the local +alias may require call-site renaming. +Review any remaining db.type references and rename SDK TailorDB schema builder +calls to db.table. Leave unrelated local objects with a .type() method unchanged. +``` + +
+ +## TailorDB forward relation names derive from field names + +**Migration:** Partially automatic + +Review TailorDB relations that omit `toward.as`. Their forward GraphQL field names now derive from the relation field name with a trailing `ID`, `Id`, or `id` removed, instead of from the target table name. + +Preserve the v1 GraphQL field name by making it explicit: + +Before: + +```ts +ownerId: db.uuid().relation({ + type: "n-1", + toward: { type: user }, +}), +``` + +After: + +```ts +ownerId: db.uuid().relation({ + type: "n-1", + toward: { type: user, as: "user" }, +}), +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +Tailor SDK v2 derives a default forward GraphQL relation name from the source +field name by removing a trailing ID, Id, or id. V1 derived it from the target +table name. Review each reported non-self relation that omits toward.as. + +If consumers must keep using the v1 GraphQL field name, inspect the v1 schema and +copy that exact field name into toward.as. Otherwise, update GraphQL operations +and consumer code to use the new field-based name. No change is needed when the old +and new names are identical. Relations with a guaranteed non-empty toward.as, +self-relations, and keyOnly relations are unchanged. For an empty or dynamic +toward.as, determine whether its runtime value can be falsy; if so, treat the +relation as using the default name. + +A relation field without a trailing ID, Id, or id would default to its own scalar +field name and therefore conflict. Give that relation an explicit toward.as. +``` + +
+ +## executeScript arg JSON.stringify → value + +**Migration:** Partially automatic + +Unwrap `JSON.stringify(...)` passed as the `executeScript` `arg` option. In v2 `arg` takes a JSON-serializable value and is serialized internally, so a pre-stringified argument double-encodes. + +Before: + +```ts +await executeScript({ ...opts, arg: JSON.stringify({ a: 1 }) }); +``` + +After: + +```ts +await executeScript({ ...opts, arg: { a: 1 } }); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2 the executeScript() arg option takes a JSON-serializable value +and is serialized internally, so a pre-stringified argument double-encodes. The +codemod already rewrote the direct form arg: JSON.stringify(X) to arg: X. Review +the executeScript calls in these files for cases it could not rewrite — where the +arg value is reached indirectly, for example: +- a variable holding a JSON.stringify(...) result (const s = JSON.stringify(x); ... arg: s) +- JSON.stringify(x, null, 2) or another multi-argument form +- an options object built or spread dynamically + +For each such call, pass the underlying value directly as arg (drop the +JSON.stringify wrapper) so executeScript serializes it once. Leave calls that +already pass a plain value unchanged. +``` + +
+ +## defineWaitPoint/defineWaitPoints → createWaitPoint/createWaitPoints + +**Migration:** Partially automatic + +Rename `defineWaitPoint` and `defineWaitPoints` to `createWaitPoint` and `createWaitPoints`. The functions create runtime instances with `.wait()` / `.resolve()` methods, so the `create*` prefix is used consistently. + +Before: + +```ts +import { defineWaitPoints } from "@tailor-platform/sdk"; + +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); +``` + +After: + +```ts +import { createWaitPoints } from "@tailor-platform/sdk"; + +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); +``` + +## workflow.triggerWorkflow/triggerJobFunction/resumeWorkflow → startWorkflow/startJobFunction/resumeWorkflowExecution + +**Migration:** Partially automatic + +Rename tailor.workflow call sites from the pre-alignment triggerWorkflow/triggerJobFunction/resumeWorkflow names to the canonical startWorkflow/startJobFunction/resumeWorkflowExecution names, on both the ambient tailor.workflow global and a workflow value imported from @tailor-platform/sdk/runtime(/workflow). For a renamed triggerWorkflow call, also renames a literal `invoker` option key to `authInvoker` — startWorkflow's options expect the platform shape directly, unlike the removed triggerWorkflow wrapper, which converted invoker to authInvoker internally. + +Before: + +```ts +import { workflow } from "@tailor-platform/sdk/runtime"; + +await workflow.triggerWorkflow("myWorkflow", { data: "value" }); +``` + +After: + +```ts +import { workflow } from "@tailor-platform/sdk/runtime"; + +await workflow.startWorkflow("myWorkflow", { data: "value" }); +``` + +A literal invoker option is renamed to authInvoker: + +Before: + +```ts +await workflow.triggerWorkflow("myWorkflow", { data: "value" }, { invoker: myInvoker }); +``` + +After: + +```ts +await workflow.startWorkflow("myWorkflow", { data: "value" }, { authInvoker: myInvoker }); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +The pre-alignment tailor.workflow names triggerWorkflow, triggerJobFunction, and +resumeWorkflow are removed from the SDK's type surface in v2; use the canonical +startWorkflow, startJobFunction, and resumeWorkflowExecution names instead. The +codemod rewrites direct member-access call sites on the ambient tailor.workflow +global and on a workflow value imported from @tailor-platform/sdk/runtime or +@tailor-platform/sdk/runtime/workflow (including aliased imports). It skips a +file entirely when a local declaration shadows the workflow import or the +ambient tailor name, to avoid rewriting an unrelated same-named value — review +those manually. + +For a renamed triggerWorkflow call, the codemod also renames a literal invoker +option key (including shorthand { invoker }) to authInvoker, since startWorkflow +expects the platform's authInvoker shape directly while triggerWorkflow's removed +wrapper converted invoker to authInvoker internally. + +Also review, and migrate by hand: +- Destructured references (e.g. const { triggerWorkflow } = workflow) — the + codemod only rewrites direct member-access calls. +- Imported TriggerWorkflowOptions / TriggerJobFunctionOptions types — rename + them to StartWorkflowOptions / StartJobFunctionOptions. +- An invoker option passed via a variable or spread (not a literal object) — + the codemod only inspects literal object arguments; rename the invoker key + to authInvoker in the options object's own definition. +``` + +
+ +## openDownloadStream → downloadStream + +**Migration:** Manual + +The deprecated `openDownloadStream` file-streaming API is removed in v2. Use `downloadStream` for streamed file downloads. The generated file utilities now emit `downloadFileStream` (which calls `downloadStream` and returns `FileDownloadStreamResponse`) instead of the removed `openFileDownloadStream` helper. + +Before: + +```ts +const res = await openDownloadStream(namespace, typeName, fieldName, recordId); +``` + +After: + +```ts +const res = await downloadStream(namespace, typeName, fieldName, recordId); +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +The openDownloadStream file-streaming API is removed in v2. Replace every call to +openDownloadStream with downloadStream (same arguments). If you used the generated +openFileDownloadStream helper, switch to downloadFileStream, which calls +downloadStream and returns FileDownloadStreamResponse. +``` + +
+ +## Ambient runtime globals are opt-in + +**Migration:** Partially automatic + +Importing `@tailor-platform/sdk` no longer activates the ambient `tailor.*` / `tailordb.*` global declarations. The codemod rewrites simple direct `new tailor.idp.Client(...)` calls to the typed `idp.Client` wrapper from `@tailor-platform/sdk/runtime`; broader runtime global usage remains review-only. Only if you relied on the ambient globals directly, add `import "@tailor-platform/sdk/runtime/globals"`. (The capital-cased `Tailordb.*` namespace is removed separately — see the `Tailordb → tailordb` codemod.) + +Preferred: switch to the typed wrappers from `@tailor-platform/sdk/runtime` and drop the ambient globals: + +Before: + +```ts +const client = new tailor.idp.Client(); +``` + +After: + +```ts +import { idp } from "@tailor-platform/sdk/runtime"; +const client = new idp.Client({ namespace: "my-namespace" }); +``` + +Fallback: only if you must keep referencing the bare `tailor.*` names, opt into the global declarations: + +Before: + +```ts +const client = new tailor.idp.Client(); +``` + +After: + +```ts +import "@tailor-platform/sdk/runtime/globals"; +const client = new tailor.idp.Client(); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +The v2 SDK no longer enables ambient Tailor runtime globals from +`@tailor-platform/sdk`. For each flagged file that uses `tailor.*`, +`tailordb.*`, or Tailor runtime error globals, prefer migrating to the +typed wrappers from `@tailor-platform/sdk/runtime`. The codemod already +rewrites direct `new tailor.idp.Client(...)` calls to `new idp.Client(...)` +when the file has no conflicting `tailor` or `idp` binding. For any remaining +`tailor.idp.Client` references, either resolve the binding collision and use +`idp.Client`, or keep the ambient global deliberately. + +Only when the file must keep referencing the bare `tailor.*` names directly, +opt into the global declarations instead by adding one of these: +- per-file: `import "@tailor-platform/sdk/runtime/globals";` +- project-wide: `"types": ["@tailor-platform/sdk/runtime/globals"]` in + the relevant tsconfig compilerOptions + +Leave files unchanged when the matching name is local, imported from another +module, or appears only in comments or prose strings. Embedded code strings +that use runtime globals are review-only findings; do not insert imports inside +string literals. +``` + +
+ +## Workflow job start() and start tests + +**Migration:** Manual + +Workflow job `.start()` (previously `.trigger()`) now aligns with the platform runtime: it returns the job result directly instead of a Promise wrapper, and tests no longer run job bodies locally. Mock start responses with `mockWorkflow()` (`setJobHandler` / `enqueueResult`, assert via `startedJobs`), or use `runWorkflowLocally()` for a full-chain local run. + +Tests must mock the workflow runtime instead of running bodies locally: + +Before: + +```ts +const result = await orderJob.start({ id }); +expect(result.status).toBe("done"); +``` + +After: + +```ts +using wf = mockWorkflow(); +wf.setJobHandler((jobName) => (jobName === "order-job" ? { status: "done" } : null)); +const result = await orderJob.start({ id }); +expect(result.status).toBe("done"); +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +Workflow job .start() now uses the platform workflow runtime instead of running +the job body locally. In tests, acquire `using wf = mockWorkflow()` and provide +start responses (setJobHandler / enqueueResult), or use runWorkflowLocally() for a +full-chain local run; an unmocked start now throws. Outside tests, treat the +start result as the job output directly (no Promise wrapper to unwrap). +``` + +
+ +## Workflow.trigger()/WorkflowJob.trigger() → .start() + +**Migration:** Manual + +Rename `Workflow.trigger()` (returned by `createWorkflow()`) and `WorkflowJob.trigger()` (returned by `createWorkflowJob()`) to `.start()`, aligning the SDK's ergonomic verb with the platform's `start*` RPC vocabulary. No codemod ships for this rename: distinguishing a workflow/job `.trigger()` call from an unrelated object's own `.trigger()` method requires resolving the receiver back to a `createWorkflow`/`createWorkflowJob` result across files, which the SDK's own CLI bundler already does for build-time rewriting. Reusing that logic in a standalone script is a nontrivial lift, and — unlike the bundler, which fails loudly when it cannot rewrite a call — a codemod false positive would silently rewrite an unrelated `.trigger()` call with no error. For the call-site volume this rename typically involves, manual review guided by the prompt below is the safer trade-off. + +Before: + +```ts +const inventory = checkInventory.trigger({ orderId: input.orderId }); +const workflowRunId = await orderProcessingWorkflow.trigger(args, { invoker: "manager" }); +``` + +After: + +```ts +const inventory = checkInventory.start({ orderId: input.orderId }); +const workflowRunId = await orderProcessingWorkflow.start(args, { invoker: "manager" }); +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +In Tailor SDK v2, the ergonomic .trigger() method on a createWorkflow() or +createWorkflowJob() result is renamed to .start(). This is unrelated to the +separate tailor.workflow.triggerWorkflow/triggerJobFunction/resumeWorkflow removal +(see the workflow-trigger-rename codemod) — this rename targets the SDK's own +ergonomic wrapper, not the low-level platform call. + +For each flagged `.trigger(` call in these files: +1. Confirm the receiver is a workflow or job object — typically a local const + assigned from createWorkflow(...)/createWorkflowJob(...), a named import of one, + or the default import of a workflow module. Skip receivers that are unrelated + objects with their own .trigger() method (state machines, event emitters, etc.). +2. Rename the call from .trigger(...) to .start(...); the argument list is unchanged. +3. Update any mock/test code that reads WorkflowJob['trigger'] / Workflow['trigger'] + as a type, or that mocks the ergonomic method via a wrapper — for example, + `wf.job(definition)` / `wf.workflow(definition)` from mockWorkflow() now return a + mock of the `.start` method. +4. Update prose/docs/comments that say "trigger the workflow/job" to "start" only + where they describe this SDK verb specifically, not unrelated event terminology. +``` + +
+ +## tailor-sdk binary → tailor + +**Migration:** Partially automatic + +Rename the CLI binary from `tailor-sdk` to `tailor` in package.json scripts, shell scripts, CI workflows, source files, generated declaration comments, and documentation. Does not rename `.tailor-sdk` directory paths or the `create-tailor-sdk` scaffolding package. Note: v2 also changes the default generated output directory from `.tailor-sdk/` to `.tailor/` and the setup lock file from `.github/tailor-sdk.lock` to `.github/tailor.lock`. Run `mv .tailor-sdk .tailor` to migrate the generated output directory (preserves auth connection state and other local files). Run `git mv .github/tailor-sdk.lock .github/tailor.lock` if the old lock file exists; without it `tailor setup check` will treat all managed workflows as missing. Exact ignore-file entries for `.tailor-sdk/` are handled by the generated-output ignore codemod. + +Before: + +```sh +tailor-sdk deploy +npx tailor-sdk@latest login +``` + +After: + +```sh +tailor deploy +npx @tailor-platform/sdk@latest login +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +Rename any remaining `tailor-sdk` binary invocations to `tailor`. Only rewrite +the binary name — leave `.tailor-sdk` directory paths and `create-tailor-sdk` +package references unchanged. +``` + +
+ +## .tailor-sdk ignore entries → .tailor + +**Migration:** Automatic + +Rewrite exact ignore-file entries for the v1 generated output directory from `.tailor-sdk` to the v2 `.tailor` directory. Other `.tailor-sdk` paths and prose are left unchanged. + +Before: + +```gitignore +.tailor-sdk/ +``` + +After: + +```gitignore +.tailor/ +``` + +## ValidateFn simplification and type-level validate + +**Migration:** Manual + +Field-level `ValidateFn` is simplified from `(args: { value, data, invoker }) => boolean` to `(args: { value }) => string | void` — the function now returns the error message directly instead of a separate `[fn, message]` tuple. The `ValidateConfig` tuple form and `Validators` record syntax on `db.type().validate()` are removed. Type-level validation uses `db.type().validate((args, issues) => void)` with `{ newRecord, oldRecord, invoker }` args and an `issues(field, message)` callback for cross-field rules. + +Field-level validate: return an error message string instead of a boolean (tuple form removed): + +Before: + +```ts +.validate( + [({ value }) => value.length > 5, "Name must be longer than 5 characters"], +) +``` + +After: + +```ts +.validate(({ value }) => + value.length <= 5 ? "Name must be longer than 5 characters" : undefined, +) +``` + +Type-level validate: per-field record syntax replaced by a single function with `issues()` callback: + +Before: + +```ts +.validate({ + name: [({ value }) => value.length > 5, "Name must be longer than 5"], +}) +``` + +After: + +```ts +.validate(({ newRecord }, issues) => { + if (newRecord.name && newRecord.name.length <= 5) { + issues("name", "Name must be longer than 5"); + } +}) +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +The v2 SDK simplifies field validation and introduces type-level validation. + +Field-level `.validate()` changes: +- Signature: `(args: { value, data, invoker }) => boolean` → `(args: { value }) => string | void` +- The function now returns the error message string directly (or undefined/void to pass) + instead of returning a boolean with the message in a separate tuple. +- The `[fn, errorMessage]` tuple form (`ValidateConfig`) is removed. +- `data` and `invoker` are no longer available in field-level validators. + Use type-level `.validate()` for cross-field or invoker-dependent rules. + +Type-level `.validate()` on `db.type()` changes: +- Old: `.validate({ fieldName: fn | [fn, msg] | fn[] })` (per-field record, `Validators` type) +- New: `.validate((args, issues) => void)` (single function, `TypeValidateFn` type) +- Args: `{ newRecord, oldRecord, invoker }` — `newRecord` is the record after hooks run +- Call `issues(field, message)` to report validation errors; `field` supports dotted paths +- Move per-field validators that need `data`/`invoker` to the type-level function + +For each remaining `ValidateConfig`, `Validators<`, or old-signature `.validate()` usage: +1. Rewrite field-level validators to return the error string directly +2. Move cross-field / invoker-dependent validators to the type-level function +3. Remove unused `ValidateConfig` / `Validators` type imports +``` + +
+ +## TailorDB hook redesign: field-level args and type-level hooks + +**Migration:** Manual + +Field-level `HookFn` args change from `{ value, data, invoker }` to create `{ input, invoker, now }` / update `{ input, oldValue, invoker, now }` — `value` is renamed to `input`, matching the `input` arg on type-level hooks (same pre-hook data, narrowed to one field); `data` (the full record) is removed; `oldValue` (previous field value) is added for update hooks only; `now` (operation timestamp) is shared across all hooks. Type-level hooks on `db.type().hooks()` change from per-field mapping `{ fieldName: { create, update } }` (`Hooks`) to a single `{ create, update }` object (`TypeHook`) — create hooks take `{ input, invoker, now }`, update hooks take `{ input, oldRecord, invoker, now }` (oldRecord is always non-null). Both return partial field overrides. + +Field-level hooks: `value` renamed to `input`, `data` replaced by `oldValue` and `now`; use `now` instead of `new Date()`: + +Before: + +```ts +db.datetime().hooks({ + create: ({ value }) => value ?? new Date(), + update: () => new Date(), +}) +``` + +After: + +```ts +db.datetime().hooks({ + create: ({ input, now }) => input ?? now, + update: ({ now }) => now, +}) +``` + +Type-level hooks: per-field mapping replaced by single create/update functions: + +Before: + +```ts +.hooks({ + fullAddress: { + create: ({ data }) => `${data.postalCode} ${data.address}`, + update: ({ data }) => `${data.postalCode} ${data.address}`, + }, +}) +``` + +After: + +```ts +.hooks({ + create: ({ input }) => ({ + fullAddress: `${input.postalCode} ${input.address}`, + }), + update: ({ input }) => ({ + fullAddress: `${input.postalCode} ${input.address}`, + }), +}) +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +The v2 SDK redesigns TailorDB hooks at both field and type levels. + +Field-level `.hooks()` on individual fields: +- Create args: `{ value, data, invoker }` → `{ input, invoker, now }` (no `oldValue`) +- Update args: `{ value, data, invoker }` → `{ input, oldValue, invoker, now }` +- `value` is renamed to `input`, matching the type-level hook's `input` arg — both are + the same pre-hook data, at different granularity +- `data` (full record) is removed; update hooks get `oldValue` (previous field value) instead +- `now` provides the operation timestamp — use `now` instead of `new Date()` +- If a field-level hook needs the full record (other fields), move it to a type-level hook + +Type-level `.hooks()` on `db.type()`: +- Old: `.hooks({ fieldName: { create: fn, update: fn } })` (per-field mapping, `Hooks` type) +- New: `.hooks({ create: fn, update: fn })` (single object, `TypeHook` type) +- Each function: `({ input, oldRecord, invoker, now }) => ({ fieldName: value, ... })` +- `input` is the pre-hook input (may have nullish values for optional/defaulted fields) +- Create hooks do not receive `oldRecord`; update hooks receive `oldRecord` (always non-null) +- Return an object with only the fields to override; unmentioned fields are unchanged + +Migration steps for each `.hooks()` call on a `db.type()`: +1. If the old per-field hooks only use `value`/`invoker` and don't reference `data`, + convert them to field-level hooks with the new args (`value` → `input`, plus `oldValue`, `now`) +2. If the old hooks reference `data` (cross-field access), convert to a type-level hook + using `input`/`oldRecord` +3. Remove unused `Hooks` / `HookFn<>` type imports +``` + +
+ +## generate --watch flag removed + +**Migration:** Manual + +Review and remove `tailor generate --watch` / `-W` invocations and the `watch` option on `GenerateOptions`. The flag, its dependency watcher, and the self-restart-on-change logic are removed; `generate` now always performs a single generation pass. + +The --watch/-W flag no longer exists; re-run generate after each change: + +Before: + +```sh +tailor generate --watch +``` + +After: + +```sh +tailor generate +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +Tailor SDK v2 removes the `generate --watch` (`-W`) flag along with the +dependency watcher and self-restart logic that powered it. `tailor generate` +now always runs a single generation pass and exits. + +For each flagged `tailor generate ... --watch` / `-W` invocation (package.json +scripts, shell scripts, CI configs, or docs), drop the flag and re-run +`tailor generate` after each change instead. If automatic regeneration on file +change is still needed, wrap the command with a general-purpose file watcher +(e.g. `chokidar-cli`, `nodemon`) at the project level. + +For programmatic use of `generate()` from `@tailor-platform/sdk/cli`, remove the +`watch` field from the `GenerateOptions` argument — the function now performs a +single generation pass and resolves once it completes. +``` + +
+ +## Behavioral changes (no migration required) + +These v2 changes alter runtime or CLI behavior; no source change is needed. + +### CLI tokens stored in the OS keyring + +CLI login tokens are stored in the OS keyring by default when available, falling back to the platform config file when it is not. No source change is required; re-login if you need tokens moved into the keyring. + +### CLI users keyed by subject ID + +The CLI stores human users by their stable subject ID instead of email (email is kept for display). Legacy email-keyed entries are migrated automatically on the next login or token refresh. No source change is required. + +### function logs require a content hash for source mapping + +`tailor function logs` maps stack traces against the function bundle only when the execution recorded a `contentHash`. Executions without one now show raw stack traces instead of mapped frames. No source change is required. + +### Node.js minimum version raised to 22.15.0 + +v2 requires Node.js **22.15.0** or later. This is the first version that includes `module.registerHooks()`, which the SDK uses to register its TypeScript loader hook synchronously in the main thread. No source change is required; ensure your environment runs Node.js 22.15.0+. + +### Legacy bundle artifact cleanup removed from deploy + +`tailor deploy` no longer deletes on-disk bundle artifacts (`.entry.js` files, workflow-job bundles, and the `hooks-validate-scripts/` directory) left in the SDK output directory (`.tailor` by default) by SDK versions that predate the current in-memory bundling approach. Current bundlers no longer write these files. No source change is required; if such stale files remain from a very old SDK version, delete only those specific files/directories manually — do not delete the output directory itself, since it also holds deploy state (e.g. `secrets-state/`, `*.context.json`) that existing secrets and Auth Connections depend on. diff --git a/packages/sdk/docs/multi-environment.md b/packages/sdk/docs/multi-environment.md index 51c9828bc..ea88f382a 100644 --- a/packages/sdk/docs/multi-environment.md +++ b/packages/sdk/docs/multi-environment.md @@ -11,16 +11,16 @@ Deployment commands resolve the target workspace from, in priority order, the `- For one-off commands, pass the workspace explicitly: ```bash -tailor-sdk deploy -w +tailor deploy -w ``` For environments you switch between regularly, create a named profile per environment with the [profile commands](./cli/workspace.md#profile-create) and select it with `--profile` (`-p`) or `TAILOR_PLATFORM_PROFILE`: ```bash -tailor-sdk profile create staging -u you@example.com -w -tailor-sdk profile create production -u you@example.com -w --permission read +tailor profile create staging -u you@example.com -w +tailor profile create production -u you@example.com -w --permission read -tailor-sdk deploy -p staging +tailor deploy -p staging ``` Profiles are created with `write` permission by default. The production profile above opts into `--permission read`, which blocks write commands such as `deploy` while the profile is active — a guard against deploying to production by accident. To deploy to production deliberately, pass the workspace explicitly with `-w` without selecting the profile — the guard applies only while a profile is selected via `-p` or `TAILOR_PLATFORM_PROFILE` — or use a separate profile created with `write` permission. @@ -32,8 +32,8 @@ export TAILOR_PLATFORM_URL= export TAILOR_PLATFORM_OAUTH2_CLIENT_ID= export TAILOR_PLATFORM_CONSOLE_URL= -tailor-sdk login -tailor-sdk profile create development \ +tailor login +tailor profile create development \ -u you@example.com \ -w \ --platform-url "$TAILOR_PLATFORM_URL" \ @@ -41,11 +41,11 @@ tailor-sdk profile create development \ --console-url "$TAILOR_PLATFORM_CONSOLE_URL" unset TAILOR_PLATFORM_URL TAILOR_PLATFORM_OAUTH2_CLIENT_ID TAILOR_PLATFORM_CONSOLE_URL -tailor-sdk deploy -p development -tailor-sdk open -p development +tailor deploy -p development +tailor open -p development ``` -After the profile exists, run `tailor-sdk login -p development` to refresh the login for that Platform without re-exporting the connection variables. +After the profile exists, run `tailor login -p development` to refresh the login for that Platform without re-exporting the connection variables. ## Varying config values per environment @@ -54,17 +54,17 @@ After the profile exists, run `tailor-sdk login -p development` to refresh the l ```ini # .env.production APP_ENV=production -LOG_LEVEL=WARN +TAILOR_APP_LOG_LEVEL=WARN ``` ```bash -tailor-sdk deploy -w --env-file .env.production +tailor deploy -w --env-file .env.production ``` ```typescript export default defineConfig({ name: "my-app", - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", }); ``` diff --git a/packages/sdk/docs/plugin/custom.md b/packages/sdk/docs/plugin/custom.md index 3b50de80f..950c077ef 100644 --- a/packages/sdk/docs/plugin/custom.md +++ b/packages/sdk/docs/plugin/custom.md @@ -18,7 +18,7 @@ const myPlugin: Plugin = { export default myPlugin; // Required: must be default export ``` -This is required so that generators can use plugin-generated TailorDB types via `getGeneratedType()`. +This is required so that other plugins and generation-time hooks can use plugin-generated TailorDB types via `getGeneratedType()`. ## Plugin Interface @@ -93,7 +93,7 @@ interface Plugin { onTypeLoaded(context) { const { type, typeConfig, namespace } = context; return { - types: { archive: db.type(`Deleted_${type.name}`, { ... }) }, + types: { archive: db.table(`Deleted_${type.name}`, { ... }) }, extends: { fields: { deletedAt: db.datetime({ optional: true }) } }, executors: [{ name: `${type.name}-on-delete`, resolve: async () => await import("./on-delete"), context: { sourceType: type, namespace } }], }; @@ -122,7 +122,7 @@ Same as `TypePluginOutput` but without `extends` (namespace plugins cannot exten ```typescript onNamespaceLoaded(context) { return { - types: { auditLog: db.type("AuditLog", { action: db.string(), ... }) }, + types: { auditLog: db.table("AuditLog", { action: db.string(), ... }) }, }; }, ``` @@ -276,7 +276,7 @@ import type { ## getGeneratedType Helper -The SDK provides an async `getGeneratedType()` helper function to retrieve plugin-generated TailorDB types. This enables generators and other tools to work with types generated by plugins. +The SDK provides an async `getGeneratedType()` helper function to retrieve plugin-generated TailorDB types. This enables plugins and other tools to work with types generated by plugins. ```typescript import { join } from "node:path"; @@ -343,7 +343,7 @@ function processSoftDelete( // Generate archive type const archiveType = db - .type(`${prefix}${type.name}`, { + .table(`${prefix}${type.name}`, { originalId: db.uuid().description("ID of the deleted record"), originalData: db.string().description("JSON snapshot of deleted record"), deletedAt: db.datetime().description("When the record was deleted"), @@ -450,7 +450,7 @@ export const plugins = definePlugins( // tailordb/customer.ts export const customer = db - .type("Customer", { + .table("Customer", { name: db.string(), email: db.string(), }) diff --git a/packages/sdk/docs/plugin/index.md b/packages/sdk/docs/plugin/index.md index e265c5ac9..b382d8402 100644 --- a/packages/sdk/docs/plugin/index.md +++ b/packages/sdk/docs/plugin/index.md @@ -6,7 +6,7 @@ Plugins extend TailorDB types by automatically generating additional types, exec ## Overview -When you run `tailor-sdk generate`, the SDK: +When you run `tailor generate`, the SDK: 1. Loads all TailorDB types with plugin attachments 2. Passes each type to the attached plugins @@ -43,7 +43,7 @@ Use the `.plugin()` method to attach plugins to specific types: import { db } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), }) @@ -58,7 +58,7 @@ Some plugins accept per-type configuration: ```typescript export const customer = db - .type("Customer", { + .table("Customer", { name: db.string(), // ... }) @@ -102,18 +102,18 @@ Plugins can generate: - **Field Extensions**: Additional fields added to the source type - **Output Files**: TypeScript code and other files via generation-time hooks -Generated files are placed under `.tailor-sdk//` (the plugin ID is sanitized, +Generated files are placed under `.tailor//` (the plugin ID is sanitized, e.g. `@example/soft-delete` → `example-soft-delete`), such as: -- `.tailor-sdk/example-soft-delete/types` -- `.tailor-sdk/example-soft-delete/executors` +- `.tailor/example-soft-delete/types` +- `.tailor/example-soft-delete/executors` ## Plugin Lifecycle -Plugins have 5 hooks across two lifecycle phases. Each hook fires at a specific point in the `tailor-sdk generate` pipeline: +Plugins have 5 hooks across two lifecycle phases. Each hook fires at a specific point in the `tailor generate` pipeline: ``` -tailor-sdk generate +tailor generate │ ├─ Load TailorDB types │ ├─ onTypeLoaded ← per type with .plugin() attached @@ -149,7 +149,7 @@ These hooks produce TailorDB types, resolvers, and executors that become part of | `onResolverReady` | TailorDB types, Resolvers, Auth | Write output files | | `onExecutorReady` | TailorDB types, Resolvers, Executors, Auth | Write output files | -These hooks receive all finalized data and produce output files (TypeScript code, etc.). They replace the previous standalone `defineGenerators()` approach. No `importPath` required. +These hooks receive all finalized data and produce output files (TypeScript code, etc.). No `importPath` required. A plugin can implement hooks from either or both phases. diff --git a/packages/sdk/docs/quickstart.md b/packages/sdk/docs/quickstart.md index b3071e772..5bd5bb831 100644 --- a/packages/sdk/docs/quickstart.md +++ b/packages/sdk/docs/quickstart.md @@ -12,7 +12,7 @@ Contact us [here](https://www.tailor.tech/demo) to get started. ### Install Node.js -The SDK requires Node.js 22 or later. Install Node.js via your package manager by following the official Node.js instructions. +The SDK requires Node.js 22.15.0 or later. Install Node.js via your package manager by following the official Node.js instructions. Alternatively, you can use [Bun](https://bun.sh/) as the runtime. @@ -24,20 +24,22 @@ The following command creates a new project with the required configuration file ```bash npm create @tailor-platform/sdk -- --template hello-world example-app +cd example-app # Or with Bun: # bun create @tailor-platform/sdk --template hello-world example-app +# cd example-app ``` Before deploying your app, you need to create a workspace: ```bash -npx tailor-sdk login -npx tailor-sdk workspace create --name --region -npx tailor-sdk workspace list +npx tailor login +npx tailor workspace create --name --region +npx tailor workspace list # Or with Bun: -# bunx tailor-sdk login -# bunx tailor-sdk workspace create --name --region +# bunx tailor login +# bunx tailor workspace create --name --region # OR # Create a new workspace using Tailor Platform Console @@ -49,7 +51,6 @@ npx tailor-sdk workspace list Run the deploy command to deploy your project: ```bash -cd example-app npm run deploy -- --workspace-id # Or with Bun: # bun run deploy --workspace-id diff --git a/packages/sdk/docs/runtime.md b/packages/sdk/docs/runtime.md index 57aef0541..b3765c0a0 100644 --- a/packages/sdk/docs/runtime.md +++ b/packages/sdk/docs/runtime.md @@ -45,17 +45,17 @@ const { url } = await aigateway.get("my-aigateway"); Each namespace can also be imported individually so you only pull what you need: ```ts -import * as iconv from "@tailor-platform/sdk/runtime/iconv"; -import type { ListUsersResponse, ClientConfig } from "@tailor-platform/sdk/runtime/idp"; +import { iconv } from "@tailor-platform/sdk/runtime/iconv"; +import { idp, type ListUsersResponse, type ClientConfig } from "@tailor-platform/sdk/runtime/idp"; ``` +`TailorContextAPI` and `TailorWorkflowAPI` describe the imported SDK wrapper objects. When typing direct access to the platform-provided globals or a runtime mock, use `PlatformContextAPI` or `PlatformWorkflowAPI` instead. + ## Activating the global types Most users do not need to touch the globals entry — `@tailor-platform/sdk/runtime` (and its subpath modules) cover the same surface without depending on any ambient declaration. -For backwards compatibility with the previous `@tailor-platform/function-types`-based setup, the SDK still activates the ambient `tailor.*` / `tailordb.*` types automatically when you import from `@tailor-platform/sdk`. **This implicit activation will be removed in v2.0**; new code should prefer the typed wrappers from `@tailor-platform/sdk/runtime`. - -If you want to opt into the globals explicitly (or you are migrating ahead of v2.0), add a single side-effect import anywhere in your project: +Importing from `@tailor-platform/sdk` does not activate the ambient `tailor.*` / `tailordb.*` declarations. If you want to opt into the globals, add a single side-effect import anywhere in your project: ```ts import "@tailor-platform/sdk/runtime/globals"; @@ -71,6 +71,8 @@ Or register the entry in `tsconfig.json`: } ``` +The globals entry exposes the lowercase `tailordb.*` namespace only. If your project still references the removed capital-cased `Tailordb.*` namespace from `@tailor-platform/function-types`, migrate before upgrading in two steps: run `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` to rewrite `Tailordb.*` references to lowercase `tailordb.*`, then add the `import "@tailor-platform/sdk/runtime/globals"` opt-in above so the rewritten references resolve. + ## Namespaces The runtime entry re-exports the following namespaces. Detailed signatures, parameters, and return types live in the JSDoc next to each export — hover the symbol in your IDE or browse the source. @@ -79,9 +81,9 @@ The runtime entry re-exports the following namespaces. Detailed signatures, para - `secretmanager` — secret-vault access (`getSecret`, `getSecrets`) - `authconnection` — OAuth-style connection tokens (`getConnectionToken`) - `idp` — IdP user management (`new Client({ namespace })`) -- `workflow` — workflow & job control (`startWorkflow`, `resumeWorkflowExecution`, `startJobFunction`, `wait`, `resolve`; the pre-alignment names `triggerWorkflow`, `resumeWorkflow`, `triggerJobFunction` are kept as frozen aliases) +- `workflow` — workflow & job control (`startWorkflow`, `resumeWorkflowExecution`, `startJobFunction`, `wait`, `resolve`) - `context` — execution context (`getInvoker`) -- `file` — `tailordb.file` BLOB API (`upload`, `download`, `downloadAsBase64`, `delete`, `getMetadata`, `downloadStream`, `uploadStream`, `openDownloadStream` _(deprecated)_) +- `file` — `tailordb.file` BLOB API (`upload`, `download`, `downloadAsBase64`, `delete`, `getMetadata`, `downloadStream`, `uploadStream`) - `aigateway` — AI Gateway URL resolution (`get`) ## Testing diff --git a/packages/sdk/docs/services/aigateway.md b/packages/sdk/docs/services/aigateway.md index bcc79afad..3b846bec2 100644 --- a/packages/sdk/docs/services/aigateway.md +++ b/packages/sdk/docs/services/aigateway.md @@ -110,6 +110,6 @@ const { url } = await aigateway.get("my-aigateway"); // await aigateway.get("unknown"); // Type error — only "my-aigateway" is allowed ``` -Type narrowing is provided by the generated `tailor.d.ts` (the `AIGatewayNameRegistry` interface). Run `tailor-sdk generate` (or `deploy`) after defining new AI Gateways to refresh it. Before the first generate run, `get()` accepts any string. +Type narrowing is provided by the generated `tailor.d.ts` (the `AIGatewayNameRegistry` interface). Run `tailor generate` (or `deploy`) after defining new AI Gateways to refresh it. Before the first generate run, `get()` accepts any string. -The same URL is also shown by `tailor-sdk show`, which lists the URL of each AI Gateway defined in `aiGateways` once it has been deployed. +The same URL is also shown by `tailor show`, which lists the URL of each AI Gateway defined in `aiGateways` once it has been deployed. diff --git a/packages/sdk/docs/services/auth.md b/packages/sdk/docs/services/auth.md index 450eebcb8..c9fd93ab1 100644 --- a/packages/sdk/docs/services/auth.md +++ b/packages/sdk/docs/services/auth.md @@ -86,7 +86,7 @@ Example TailorDB type for user profile: // tailordb/user.ts import { db } from "@tailor-platform/sdk"; -export const user = db.type("User", { +export const user = db.table("User", { email: db.string().unique(), // usernameField must have unique constraint role: db.enum(["admin", "user"]), ...db.fields.timestamps(), @@ -130,7 +130,7 @@ Example TailorDB type with UUID fields for attribute list: // tailordb/user.ts import { db } from "@tailor-platform/sdk"; -export const user = db.type("User", { +export const user = db.table("User", { email: db.string().unique(), role: db.enum(["admin", "user"]), organizationId: db.uuid(), // Can be used in attributeList @@ -139,18 +139,18 @@ export const user = db.type("User", { }); ``` -The `attributeList` values are accessible via `user.attributeList` as a tuple: +The `attributeList` values are accessible via the runtime principal's `attributeList` as a tuple: ```typescript // In a resolver body: (context) => { - const [organizationId, teamId] = context.user.attributeList; + const [organizationId, teamId] = context.caller?.attributeList ?? []; }, // In TailorDB hooks .hooks({ field: { - create: ({ user }) => user.attributeList[0], // First UUID from list + create: ({ invoker }) => invoker?.attributeList[0] ?? null, // First UUID from list }, }) ``` @@ -160,7 +160,7 @@ body: (context) => { When you want to use machine users without defining a `userProfile`, define `machineUserAttributes` instead. These attributes are used for: - type-safe `machineUsers[*].attributes` -- `context.user.attributes` typing (via `tailor.d.ts`) +- runtime principal `attributes` typing (via `tailor.d.ts`) ```typescript import { defineAuth, t } from "@tailor-platform/sdk"; @@ -182,7 +182,7 @@ export const auth = defineAuth("my-auth", { To update types in `tailor.d.ts`, run: ```bash -tailor-sdk generate +tailor generate ``` ## Machine Users @@ -200,12 +200,12 @@ machineUsers: { }, ``` -**attributes**: Values for attributes enabled in `userProfile.attributes` (or fields defined in `machineUserAttributes` when `userProfile` is omitted). Attribute keys mirror the field's optionality: attributes derived from required fields must be set, while attributes derived from optional fields may be omitted. Setting an attribute to `null` or `undefined` is equivalent to omitting it — the attribute is deployed as unset. These values are accessible via `user.attributes`: +**attributes**: Values for attributes enabled in `userProfile.attributes` (or fields defined in `machineUserAttributes` when `userProfile` is omitted). Attribute keys mirror the field's optionality: attributes derived from required fields must be set, while attributes derived from optional fields may be omitted. Setting an attribute to `null` or `undefined` is equivalent to omitting it — the attribute is deployed as unset. These values are accessible via the runtime principal's `attributes`: ```typescript // In a resolver body: (context) => { - const role = context.user.attributes?.role; + const role = context.caller?.attributes.role; }, ``` @@ -230,25 +230,25 @@ machineUsers: { }, ``` -These values are accessible via `user.attributeList`: +These values are accessible via the runtime principal's `attributeList`: ```typescript // In a resolver body: (context) => { - const [organizationId, teamId] = context.user.attributeList; + const [organizationId, teamId] = context.caller?.attributeList ?? []; }, // In TailorDB hooks .hooks({ field: { - create: ({ user }) => user.attributes?.role === "ADMIN" ? "default" : null, + create: ({ invoker }) => invoker?.attributes.role === "ADMIN" ? "default" : null, }, }) // In TailorDB validate .validate({ field: [ - ({ user }) => user.attributes?.role === "ADMIN", + ({ invoker }) => invoker?.attributes.role === "ADMIN", "Only admins can set this field", ], }) @@ -264,12 +264,12 @@ Machine users are useful for: Get a machine user token using the CLI: ```bash -tailor-sdk machineuser token +tailor machineuser token ``` ### Specifying a machine user invoker -Resolvers, executors, and `workflow.trigger()` accept an `authInvoker` option that chooses which machine user runs the operation. Pass the machine user name as a plain string — it is type-narrowed to the names you registered in `machineUsers`. +Resolvers, executors, and `workflow.start()` accept an `invoker` option that chooses which machine user runs the operation. Pass the machine user name as a plain string — it is type-narrowed to the names you registered in `machineUsers`. ```typescript // tailor.config.ts @@ -284,21 +284,21 @@ export const auth = defineAuth("my-auth", { ``` ```typescript -// resolvers/trigger-workflow.ts +// resolvers/start-workflow.ts import { createResolver, t } from "@tailor-platform/sdk"; import myWorkflow from "../workflows/my-workflow"; export default createResolver({ - name: "triggerMyWorkflow", + name: "startMyWorkflow", operation: "mutation", input: { id: t.string(), }, body: async ({ input }) => { - // Trigger workflow with machine user permissions - const workflowRunId = await myWorkflow.trigger( + // Start workflow with machine user permissions + const workflowRunId = await myWorkflow.start( { id: input.id }, - { authInvoker: "admin-machine-user" }, + { invoker: "admin-machine-user" }, ); return { workflowRunId }; }, @@ -308,9 +308,7 @@ export default createResolver({ }); ``` -Type narrowing is provided by the generated `tailor.d.ts` (the `MachineUserNameRegistry` interface). Run `tailor-sdk generate` (or `deploy`) after defining new machine users to refresh it. - -> **Deprecated:** The `auth.invoker("")` helper is still available for backward compatibility. Prefer the string form — it does not require importing `auth` from `tailor.config.ts` into runtime files, avoiding bundling config-layer (Node-only) dependencies. +Type narrowing is provided by the generated `tailor.d.ts` (the `MachineUserNameRegistry` interface). Run `tailor generate` (or `deploy`) after defining new machine users to refresh it. ## OAuth 2.0 Clients @@ -350,7 +348,7 @@ oauth2Clients: { Get OAuth2 client credentials using the CLI: ```bash -tailor-sdk oauth2client get +tailor oauth2client get ``` ## Identity Provider @@ -373,9 +371,9 @@ For the official Tailor Platform documentation, see [AuthConnection Guide](https > Deploy updates connections **in-place**, preserving the OAuth token. If the connection requires re-authorization after an update, the deploy will warn you: > > ```bash -> tailor-sdk authconnection authorize --name +> tailor authconnection authorize --name > # Or via the Console: -> tailor-sdk authconnection open +> tailor authconnection open > ``` ### Setup Flow @@ -408,10 +406,10 @@ export const auth = defineAuth("my-auth", { }); ``` -After `tailor-sdk deploy`, authorize the connection: +After `tailor deploy`, authorize the connection: ```bash -tailor-sdk authconnection authorize --name google-connection \ +tailor authconnection authorize --name google-connection \ --scopes "openid,profile,email" ``` @@ -445,9 +443,9 @@ const response = await fetch("https://www.googleapis.com/...", { // authconnection.getConnectionToken("unknown"); // Type error — only "google-connection" is allowed ``` -Type narrowing is provided by the generated `tailor.d.ts` (the `ConnectionNameRegistry` interface). Run `tailor-sdk generate` (or `deploy`) after defining new connections to refresh it. Before the first generate, or when `connections` is not defined in `defineAuth()`, `getConnectionToken()` accepts any string — this also supports connections managed entirely via the CLI. +Type narrowing is provided by the generated `tailor.d.ts` (the `ConnectionNameRegistry` interface). Run `tailor generate` (or `deploy`) after defining new connections to refresh it. Before the first generate, or when `connections` is not defined in `defineAuth()`, `getConnectionToken()` accepts any string — this also supports connections managed entirely via the CLI. -> **Deprecated:** `auth.getConnectionToken("")` still works, but is deprecated. Importing `auth` from `tailor.config.ts` into runtime files pulls config-layer (Node-only) dependencies into the bundle. +This keeps runtime files independent from `tailor.config.ts`. See [Built-in Interfaces](https://docs.tailor.tech/guides/function/builtin-interfaces.html#auth-connection) for the full runtime API. @@ -457,19 +455,19 @@ Auth connections can also be managed via the CLI: ```bash # Open the connections page in the Console (recommended for creating connections/tokens) -tailor-sdk authconnection open +tailor authconnection open # Authorize (opens browser for OAuth2 flow) -tailor-sdk authconnection authorize --name google-connection +tailor authconnection authorize --name google-connection # List all connections -tailor-sdk authconnection list +tailor authconnection list # Revoke a connection -tailor-sdk authconnection revoke --name google-connection +tailor authconnection revoke --name google-connection ``` -Connection creation is handled by `tailor-sdk deploy` via the config, but recreation on deploy can drop the authorized token (see the warning at the top of this section) — for shared and CI workflows, create connections and tokens from the Console (`tailor-sdk authconnection open`) instead. +Connection creation and updates are handled by `tailor deploy` via the config. Deploy updates connections in-place, preserving the authorized token, and warns you if an update requires re-authorization (see the note above). See [Auth Resource Commands](../cli/auth.md) for full CLI documentation. @@ -532,21 +530,21 @@ Manage Auth resources using the CLI: ```bash # Auth connections -tailor-sdk authconnection authorize --name -tailor-sdk authconnection list -tailor-sdk authconnection revoke --name +tailor authconnection authorize --name +tailor authconnection list +tailor authconnection revoke --name # List machine users -tailor-sdk machineuser list +tailor machineuser list # Get machine user token -tailor-sdk machineuser token +tailor machineuser token # List OAuth2 clients -tailor-sdk oauth2client list +tailor oauth2client list # Get OAuth2 client credentials -tailor-sdk oauth2client get +tailor oauth2client get ``` See [Auth Resource Commands](../cli/auth.md) for full documentation. diff --git a/packages/sdk/docs/services/executor.md b/packages/sdk/docs/services/executor.md index 11ef602bf..784e08e10 100644 --- a/packages/sdk/docs/services/executor.md +++ b/packages/sdk/docs/services/executor.md @@ -219,7 +219,7 @@ createExecutor({ }); ``` -Executor callbacks receive the trigger args, including `env` from `defineConfig({ env })`. `function` and `jobFunction` `body` args also include an `invoker` field: the principal running this function, overridden by `authInvoker` when set; `null` for anonymous calls. Other operation kinds (`graphql`, `webhook`, `workflow`) receive `env` through their callback args but do not pass `invoker` into those callbacks. +Executor callbacks receive the trigger args, including `env` from `defineConfig({ env })`. `function` and `jobFunction` `body` args also include an `invoker` field: the principal running this function, or the machine user configured through the operation `invoker` option; `null` for anonymous calls. Other operation kinds (`graphql`, `webhook`, `workflow`) receive `env` through their callback args but do not pass `invoker` into those callbacks. ### Job Function Operation @@ -329,9 +329,14 @@ createExecutor({ }); ``` +`args` must match the workflow's main job input. It is required when that input is required +and can be omitted when the workflow has no input. Static arguments can be JSON-compatible +primitives, arrays, or plain objects; top-level `null` is not supported. An argument callback +must return the same input type. + ### Authentication for Operations -GraphQL and Workflow operations can specify an `authInvoker` to execute with machine user credentials. Pass the machine user name as a plain string — it is type-narrowed to the names defined in your auth config: +GraphQL and Workflow operations can specify an `invoker` to execute with machine user credentials. Pass the machine user name as a plain string — it is type-narrowed to the names defined in your auth config: ```typescript import { createExecutor, scheduleTrigger } from "@tailor-platform/sdk"; @@ -342,13 +347,11 @@ export default createExecutor({ operation: { kind: "graphql", query: `mutation { cleanupOldRecords { count } }`, - authInvoker: "batch-processor", + invoker: "batch-processor", }, }); ``` -> **Deprecated:** `auth.invoker("batch-processor")` still works, but is deprecated. Prefer the string form to avoid importing config-layer modules into runtime files. - ## Event Payloads Each trigger type provides specific context data in the callback functions. diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index cdc6ac61f..cb4184e0b 100644 --- a/packages/sdk/docs/services/resolver.md +++ b/packages/sdk/docs/services/resolver.md @@ -108,7 +108,7 @@ Define input/output schemas using methods of `t` object. Basic usage and support You can reuse fields defined with `db` object, but note that unsupported options will be ignored: ```typescript -const user = db.type("User", { +const user = db.table("User", { name: db.string().unique(), age: db.int(), }); @@ -208,7 +208,7 @@ Validation functions receive: - `value` - The field value being validated - `data` - The entire input object -- `user` - The user performing the operation +- `invoker` - The principal performing the operation You can specify validation as: @@ -234,13 +234,13 @@ Validation runs automatically before the `body` function executes. When validati Define actual resolver logic in the `body` function. Function arguments include: - `input` - Input data from GraphQL request -- `user` - The user who called this resolver; unaffected by `authInvoker` -- `invoker` - The principal running this function; equals `user` by default, or the machine user set by `authInvoker`. `null` for anonymous calls. +- `caller` - The user or machine user who called this resolver; unaffected by `invoker`. `null` for anonymous calls. +- `invoker` - The principal running this function; equals `caller` by default, or the machine user configured through the resolver `invoker` option. `null` for anonymous calls. - `env` - Environment variables declared in `tailor.config.ts` ### Using Kysely for Database Access -If you're generating Kysely types with a generator, you can use `getDB` to execute typed queries: +If you're generating Kysely types with `kyselyTypePlugin`, you can use `getDB` to execute typed queries: ```typescript import { getDB } from "../generated/tailordb"; @@ -306,7 +306,7 @@ createResolver({ - When `publishEvents: true`, resolver execution events are published - When not specified, it is **automatically set to `true`** if an executor uses this resolver with `resolverExecutedTrigger` -- When explicitly set to `false` while an executor uses this resolver, an error is thrown during `tailor apply` +- When explicitly set to `false` while an executor uses this resolver, an error is thrown during `tailor deploy` **Use cases:** @@ -352,7 +352,7 @@ createResolver({ ## Authentication -Specify an `authInvoker` to execute the resolver with machine user credentials. Pass the machine user name as a plain string — it is type-narrowed to the names you defined in your auth config: +Specify an `invoker` to execute the resolver with machine user credentials. Pass the machine user name as a plain string — it is type-narrowed to the names you defined in your auth config: ```typescript import { createResolver, t } from "@tailor-platform/sdk"; @@ -365,12 +365,10 @@ export default createResolver({ // Executes as "batch-processor" machine user return { result: "ok" }; }, - authInvoker: "batch-processor", + invoker: "batch-processor", }); ``` The machine user name is looked up in the auth service configured on your app (`machineUsers` in `defineAuth`). The namespace is resolved automatically — no need to import `auth` from `tailor.config.ts` in resolver files. -> **Deprecated:** `auth.invoker("batch-processor")` still works, but is deprecated. Importing `auth` into runtime files pulls config-layer (Node-only) dependencies into the bundle. - -**Note:** `authInvoker` controls the permissions for database operations and other platform actions. The `user` object passed to `body` still reflects the original caller, while `invoker` reflects the principal actually running the body. +**Note:** The `invoker` option controls the permissions for database operations and other platform actions. The `caller` object passed to `body` still reflects the original caller, while the `invoker` body field reflects the principal actually running the body. diff --git a/packages/sdk/docs/services/secret.md b/packages/sdk/docs/services/secret.md index 374cb7dd4..6cc7543fb 100644 --- a/packages/sdk/docs/services/secret.md +++ b/packages/sdk/docs/services/secret.md @@ -34,11 +34,11 @@ Secrets are key-value pairs stored within a vault. Secret values are encrypted a ## Managing Secrets -There are two ways to manage secrets: declaratively via `defineSecretManager()` in `tailor.config.ts`, or imperatively via the [CLI](#cli-management). Management is scoped per vault — **do not mix both approaches for the same vault**. When a vault is defined in config, the config becomes the source of truth: any secrets in that vault not present in the config will be deleted on `tailor-sdk deploy`. +There are two ways to manage secrets: declaratively via `defineSecretManager()` in `tailor.config.ts`, or imperatively via the [CLI](#cli-management). Management is scoped per vault — **do not mix both approaches for the same vault**. When a vault is defined in config, the config becomes the source of truth: any secrets in that vault not present in the config will be deleted on `tailor deploy`. ### Declarative Configuration -Define your secrets in `tailor.config.ts` using `defineSecretManager()`. Each key is a vault name, and its value is a record of secret names to their values. These values are deployed to each vault on `tailor-sdk deploy`. +Define your secrets in `tailor.config.ts` using `defineSecretManager()`. Each key is a vault name, and its value is a record of secret names to their values. These values are deployed to each vault on `tailor deploy`. Since secret values should not be committed to source control, use environment variables: @@ -87,7 +87,7 @@ When `ignoreNullishValues: true`: - Skipped secrets are shown in the deploy output for visibility - Secrets removed from the config entirely are still deleted (orphan cleanup) -This allows you to set secret values once (e.g., via local `tailor-sdk deploy` or the CLI) and then deploy from CI without needing the actual values in CI environment variables. +This allows you to set secret values once (e.g., via local `tailor deploy` or the CLI) and then deploy from CI without needing the actual values in CI environment variables. ## Using Secrets @@ -175,25 +175,25 @@ At runtime, these references are replaced with the actual secret values. Use the CLI to manage vaults that are **not** defined in `defineSecretManager()`. If you attempt to modify a vault that is managed by the config, the CLI will show a warning and ask for confirmation. Once confirmed, the CLI releases the vault's ownership label so it is no longer managed by config. -After ownership is released, the next `tailor-sdk deploy` will treat the vault as an unmanaged resource and prompt for confirmation before taking any action on it. +After ownership is released, the next `tailor deploy` will treat the vault as an unmanaged resource and prompt for confirmation before taking any action on it. ### Create a Vault ```bash -tailor-sdk secret vault create --name api-keys +tailor secret vault create --name api-keys ``` ### Add Secrets ```bash # Create a secret -tailor-sdk secret create \ +tailor secret create \ --vault-name api-keys \ --name stripe-secret-key \ --value sk_live_xxxxx # Update a secret -tailor-sdk secret update \ +tailor secret update \ --vault-name api-keys \ --name stripe-secret-key \ --value sk_live_yyyyy @@ -203,20 +203,20 @@ tailor-sdk secret update \ ```bash # List vaults -tailor-sdk secret vault list +tailor secret vault list # List secrets in a vault (values are hidden) -tailor-sdk secret list --vault-name api-keys +tailor secret list --vault-name api-keys ``` ### Delete Secrets ```bash # Delete a secret -tailor-sdk secret delete --vault-name api-keys --name old-key --yes +tailor secret delete --vault-name api-keys --name old-key --yes # Delete a vault (must be empty) -tailor-sdk secret vault delete --name old-vault --yes +tailor secret vault delete --name old-vault --yes ``` See [Secret CLI Commands](../cli/secret.md) for full documentation. diff --git a/packages/sdk/docs/services/staticwebsite.md b/packages/sdk/docs/services/staticwebsite.md index 70143c61a..6a7c6dcc0 100644 --- a/packages/sdk/docs/services/staticwebsite.md +++ b/packages/sdk/docs/services/staticwebsite.md @@ -72,7 +72,7 @@ defineStaticWebSite("my-website", { }); ``` -After deploying, use `tailor-sdk staticwebsite domain get ` to check domain status and retrieve the CNAME targets required for DNS configuration. +After deploying, use `tailor staticwebsite domain get ` to check domain status and retrieve the CNAME targets required for DNS configuration. A domain can be associated with only one workspace at a time. To set custom domains only in the workspace that owns the domain, see [Multi-Environment Configuration](../multi-environment.md#settings-that-belong-to-a-single-environment). diff --git a/packages/sdk/docs/services/tailordb-migration.md b/packages/sdk/docs/services/tailordb-migration.md index 85fc8adc4..61f753f48 100644 --- a/packages/sdk/docs/services/tailordb-migration.md +++ b/packages/sdk/docs/services/tailordb-migration.md @@ -12,7 +12,7 @@ For the CLI command reference, see [`tailordb migration`](../cli/tailordb.md#tai - **Local snapshot–based diff detection** — each migration is generated by diffing your current type definitions against the previous snapshot stored in `migrations//`. - **Transaction-wrapped data migrations** — each `migrate.ts` script runs inside a database transaction on the platform; if the script throws, all changes in that migration roll back. -- **Automatic execution during `apply`** — `tailor-sdk deploy` detects pending migrations, runs the two-stage type update (pre-migration → script → post-migration), and updates the migration checkpoint label. +- **Automatic execution during `apply`** — `tailor deploy` detects pending migrations, runs the two-stage type update (pre-migration → script → post-migration), and updates the migration checkpoint label. - **Type-safe scripts** — the generated `db.ts` provides Kysely types that reflect the schema state **before** the migration runs, so transformations are written against the actual data shape. **Files in `migrations/`** @@ -42,24 +42,24 @@ When you start with no `migrations/` directory: 2. Define your initial types in `tailordb/`. 3. Generate the initial migration: ```bash - tailor-sdk tailordb migration generate + tailor tailordb migration generate ``` This creates `migrations/0000/schema.json` from your current types. -4. Run `tailor-sdk deploy`. The migration label is set to `0000` on the deployed namespace. +4. Run `tailor deploy`. The migration label is set to `0000` on the deployed namespace. ### Adding migrations to an existing project If you already have a deployed workspace whose schema matches your local type definitions: 1. Add the `migration` block to `tailor.config.ts`. -2. Run `tailor-sdk tailordb migration generate` to create `0000/schema.json` from current local types. -3. Run `tailor-sdk deploy`. Because remote schema already matches, no script runs; only the migration label is set. +2. Run `tailor tailordb migration generate` to create `0000/schema.json` from current local types. +3. Run `tailor deploy`. Because remote schema already matches, no script runs; only the migration label is set. If your local types and remote schema have **diverged**, reconcile them before introducing migrations — either update local types to match remote, or accept that the first non-`0000` migration will reflect that gap. ### Resetting -`tailor-sdk tailordb migration generate --init` deletes the existing `migrations/` directory and starts over from `0000`. Use this only on projects that are not yet deployed, or when you have decided to re-baseline (the next `apply` will see all migrations as new and require coordination — see [Resetting a deployed project](#resetting-a-deployed-project)). +`tailor tailordb migration generate --init` deletes the existing `migrations/` directory and starts over from `0000`. Use this only on projects that are not yet deployed, or when you have decided to re-baseline (the next `apply` will see all migrations as new and require coordination — see [Resetting a deployed project](#resetting-a-deployed-project)). ## Migration Workflow @@ -69,7 +69,7 @@ A typical change cycle: ```typescript // tailordb/user.ts - export const user = db.type("User", { + export const user = db.table("User", { name: db.string(), email: db.string(), // ← new required field ...db.fields.timestamps(), @@ -79,7 +79,7 @@ A typical change cycle: 2. **Generate the migration.** ```bash - tailor-sdk tailordb migration generate --name "add email to user" + tailor tailordb migration generate --name "add email to user" ``` Output: @@ -109,7 +109,7 @@ A typical change cycle: 4. **Apply.** ```bash - tailor-sdk deploy + tailor deploy ``` The pre-migration phase relaxes the new field to optional, the script runs and populates values, then the post-migration phase enforces `required: true`. @@ -126,10 +126,10 @@ Warning: data loss possible: No `migrate.ts` is generated automatically because the schema change itself is non-breaking, but the existing data is dropped during the post-migration phase. If you need to preserve or transform that data first (for example, copy a column into another table before it disappears), add a script with: ```bash -tailor-sdk tailordb migration script 0002 +tailor tailordb migration script 0002 ``` -This writes `migrations/0002/migrate.ts` and `migrations/0002/db.ts` next to the existing `diff.json`. The removed field stays readable inside `migrate.ts` because the pre-migration phase keeps it on the type until the script finishes (see [Per-migration phases](#per-migration-phases)). The next `tailor-sdk deploy` runs the script automatically — `migrate.ts` is executed whenever the file exists on disk, regardless of whether the diff itself required it. +This writes `migrations/0002/migrate.ts` and `migrations/0002/db.ts` next to the existing `diff.json`. The removed field stays readable inside `migrate.ts` because the pre-migration phase keeps it on the type until the script finishes (see [Per-migration phases](#per-migration-phases)). The next `tailor deploy` runs the script automatically — `migrate.ts` is executed whenever the file exists on disk, regardless of whether the diff itself required it. ## Configuration @@ -245,7 +245,7 @@ The same pattern works for switching between scalar and array. ## Automatic Migration Execution -When you run `tailor-sdk deploy`, the SDK detects pending migrations (anything past the current `sdk-migration` label on the deployed namespace) and runs them in order before continuing with the rest of the apply. +When you run `tailor deploy`, the SDK detects pending migrations (anything past the current `sdk-migration` label on the deployed namespace) and runs them in order before continuing with the rest of the apply. ### Per-migration phases @@ -280,7 +280,7 @@ The error also points you at `migration status`, `migration generate`, `migratio To bypass both checks (not recommended outside of recovery scenarios): ```bash -tailor-sdk deploy --no-schema-check +tailor deploy --no-schema-check ``` ### Example output @@ -299,7 +299,7 @@ tailor-sdk deploy --no-schema-check ## `migration set` Semantics -`tailor-sdk tailordb migration set ` updates the `sdk-migration` label on the deployed namespace's metadata. **It does not modify any data or schema.** It only changes which migrations the next `apply` will consider pending. +`tailor tailordb migration set ` updates the `sdk-migration` label on the deployed namespace's metadata. **It does not modify any data or schema.** It only changes which migrations the next `apply` will consider pending. | Movement | Effect on next `apply` | Effect on data | | -------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------- | @@ -316,7 +316,7 @@ Use cases: ## `migration sync` Semantics -`tailor-sdk tailordb migration sync ` reconstructs the schema snapshot at migration `N` from the working tree's migration history and **overwrites the remote schema to match it**, then sets the `sdk-migration` label to `N`. Unlike `migration set`, it changes the remote schema as well as the bookkeeping. Like `set`, it never runs `migrate.ts` scripts itself — it only changes what the next `apply` considers pending: +`tailor tailordb migration sync ` reconstructs the schema snapshot at migration `N` from the working tree's migration history and **overwrites the remote schema to match it**, then sets the `sdk-migration` label to `N`. Unlike `migration set`, it changes the remote schema as well as the bookkeeping. Like `set`, it never runs `migrate.ts` scripts itself — it only changes what the next `apply` considers pending: | Movement | Effect on next `apply` | Effect on data | | -------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | @@ -327,7 +327,7 @@ Before anything is sent to the remote, `sync` verifies that replaying the full m Because syncing backward causes already-applied scripts to re-execute on the next deploy, **write `migrate.ts` scripts to be idempotent** (see [Performance and Large Tables](#performance-and-large-tables) for resumable `where` clauses). -The main use case is recovering from drift after a `deploy --no-schema-check` from an older revision: instead of checking out that revision, run `migration sync ` to restore the remote to a known snapshot, then `tailor-sdk deploy` to apply the remaining migrations from the working tree. +The main use case is recovering from drift after a `deploy --no-schema-check` from an older revision: instead of checking out that revision, run `migration sync ` to restore the remote to a known snapshot, then `tailor deploy` to apply the remaining migrations from the working tree. ## Team Workflow and CI/CD @@ -342,7 +342,7 @@ Migration numbers are assigned sequentially, so two developers branching off the ### CI / CD - For non-interactive environments, pass `--yes` to `migration generate` and `--yes` to `apply`. `apply` runs migrations automatically when the `migrations/` directory is configured. -- Run `tailor-sdk tailordb migration status` in CI to detect "developer forgot to commit a migration" situations early. The exit code is non-zero only on errors, so check the output. +- Run `tailor tailordb migration status` in CI to detect "developer forgot to commit a migration" situations early. The exit code is non-zero only on errors, so check the output. - Avoid running migrations in parallel against the same workspace — there is no locking. Serialize deploys per environment. ### Resetting a deployed project @@ -350,8 +350,8 @@ Migration numbers are assigned sequentially, so two developers branching off the `migration generate --init` is destructive locally but does not touch the deployed workspace. Re-baselining a deployed project requires: 1. Run `migration generate --init` to start over from `0000`. -2. Run `tailor-sdk tailordb migration set 0` against the deployed namespace. -3. Run `tailor-sdk deploy` — the new `0000` becomes the baseline. +2. Run `tailor tailordb migration set 0` against the deployed namespace. +3. Run `tailor deploy` — the new `0000` becomes the baseline. Coordinate this with your team because everyone else's local migrations will be invalidated. @@ -369,7 +369,7 @@ After a failure: 1. Read the `Logs:` block in the apply output to find the cause. 2. Fix `migrate.ts` (or the data it depends on). -3. Re-run `tailor-sdk deploy`. The same migration runs again because its label was never bumped, and the prior-checkpoint schema is a clean baseline to retry against. +3. Re-run `tailor deploy`. The same migration runs again because its label was never bumped, and the prior-checkpoint schema is a clean baseline to retry against. If a migration **succeeds in script** but the **post-migration phase** fails (rare; usually a constraint violation the script should have prevented), the pre-migration changes are **not** rolled back: the script's data changes are already committed and the post-migration phase may have dropped removed columns or types, which cannot be reverted without data loss. Investigate, fix, and re-run. @@ -398,7 +398,7 @@ The machine user needs read/write access to every type the migration script touc If you see `No machine user available for migration execution`, either: -- Add `machineUsers: { ... }` to your auth config and `tailor-sdk deploy` it, or +- Add `machineUsers: { ... }` to your auth config and `tailor deploy` it, or - Set `migration.machineUser` to an existing machine user name in the db config. ## Multi-Namespace Coordination @@ -441,7 +441,7 @@ For genuinely different schemas across environments, prefer separate workspaces **Resolution:** -1. `tailor-sdk tailordb migration status` to see local vs remote. +1. `tailor tailordb migration status` to see local vs remote. 2. Compare with teammates — has someone applied different migrations? 3. If remote was changed manually, decide whether to update local migrations to match or to use `migration set ` to align bookkeeping. 4. To force the remote schema back to a known snapshot, use `migration sync ` (see [`migration sync` Semantics](#migration-sync-semantics)). @@ -473,7 +473,7 @@ For genuinely different schemas across environments, prefer separate workspaces **Cause:** Runtime error in your `migrate.ts`, a permission error from the machine user, or a constraint violation when post-migration tightens types. -**Resolution:** Read the `Logs:` block. Fix the script or the data assumption it relies on, and re-run `tailor-sdk deploy`. The label is not bumped on failure, so the same migration retries. +**Resolution:** Read the `Logs:` block. Fix the script or the data assumption it relies on, and re-run `tailor deploy`. The label is not bumped on failure, so the same migration retries. ### `migrate.ts` not found for a migration that needs one diff --git a/packages/sdk/docs/services/tailordb.md b/packages/sdk/docs/services/tailordb.md index 8c1fe25b2..6ee478b0f 100644 --- a/packages/sdk/docs/services/tailordb.md +++ b/packages/sdk/docs/services/tailordb.md @@ -29,7 +29,7 @@ Define TailorDB Types in files matching glob patterns specified in `tailor.confi import { db } from "@tailor-platform/sdk"; // Export both value and type -export const user = db.type("User", { +export const user = db.table("User", { name: db.string(), email: db.string().unique(), age: db.int(), @@ -38,7 +38,7 @@ export const user = db.type("User", { export type user = typeof user; // You can define multiple types in the same file -export const role = db.type("Role", { +export const role = db.table("Role", { name: db.string().unique(), }); export type role = typeof role; @@ -47,7 +47,7 @@ export type role = typeof role; Specify plural form by passing an array as first argument: ```typescript -db.type(["User", "UserList"], { +db.table(["User", "UserList"], { name: db.string(), }); ``` @@ -55,7 +55,7 @@ db.type(["User", "UserList"], { Pass a description as second argument: ```typescript -db.type("User", "User in the system", { +db.table("User", "User in the system", { name: db.string(), }); ``` @@ -174,11 +174,11 @@ db.string().unique(); Add a relation to field with automatic index and foreign key constraint: ```typescript -const role = db.type("Role", { +const role = db.table("Role", { name: db.string(), }); -const user = db.type("User", { +const user = db.table("User", { name: db.string(), roleId: db.uuid().relation({ type: "n-1", @@ -190,7 +190,7 @@ const user = db.type("User", { For one-to-one relations, use `type: "1-1"`: ```typescript -const userProfile = db.type("UserProfile", { +const userProfile = db.table("UserProfile", { userId: db.uuid().relation({ type: "1-1", toward: { type: user }, @@ -202,7 +202,7 @@ const userProfile = db.type("UserProfile", { For foreign key constraint without creating a relation, use `type: "keyOnly"`: ```typescript -const user = db.type("User", { +const user = db.table("User", { roleId: db.uuid().relation({ type: "keyOnly", toward: { type: role }, @@ -213,22 +213,25 @@ const user = db.type("User", { Create relations against different fields using `toward.key`: ```typescript -const user = db.type("User", { +const user = db.table("User", { email: db.string().unique(), }); -const userProfile = db.type("UserProfile", { +const userProfile = db.table("UserProfile", { userEmail: db.string().relation({ type: "1-1", - toward: { type: user, key: "email" }, + toward: { type: user, key: "email", as: "user" }, }), }); ``` +`userEmail` does not end in `ID`, `Id`, or `id`, so this example specifies the forward relation +name with `toward.as`. + Customize relation names using `toward.as` / `backward` options: ```typescript -const userProfile = db.type("UserProfile", { +const userProfile = db.table("UserProfile", { userId: db.uuid().relation({ type: "1-1", toward: { type: user, as: "base" }, @@ -255,88 +258,101 @@ type User { - `backward` - Customizes the field name for accessing this type from the related type Relation names share the same GraphQL field namespace as fields, files, and other relations on -the type. The SDK rejects duplicate or empty relation names. Use `toward.as` when multiple fields -on the same type point to the same target type, because their default forward names are derived -from the target type name: +the table. The SDK rejects duplicate or empty relation names. When `toward.as` is omitted, the +default forward name comes from the relation field name with a trailing `ID`, `Id`, or `id` +removed. This lets multiple fields point to the same target table with distinct forward names: ```typescript -const post = db.type("Post", { +const post = db.table("Post", { authorID: db.uuid().relation({ type: "n-1", - toward: { type: user, as: "author" }, + toward: { type: user }, backward: "authoredPosts", }), reviewerID: db.uuid().relation({ type: "n-1", - toward: { type: user, as: "reviewer" }, + toward: { type: user }, backward: "reviewedPosts", }), }); ``` +These fields generate the forward names `author` and `reviewer`. A relation field without one of +the recognized ID suffixes needs an explicit `toward.as`, because its generated forward name +would conflict with the field itself. + Use `toward.as` or `backward` when a generated relation name would conflict with an existing -field, files entry, or relation on the same type. +field, files entry, or relation on the same table. ### Hooks -Add hooks to execute functions during data creation or update. Hooks receive three arguments: - -- `value`: User input if provided, otherwise existing value on update or null on create -- `data`: Entire record data (for accessing other field values) -- `user`: User performing the operation +Add hooks to execute functions during data creation or update. #### Field-level Hooks -Set hooks directly on individual fields: +Set hooks directly on individual fields. + +Create hooks receive: + +- `input`: The field value from the input (null when not provided) +- `invoker`: Principal performing the operation +- `now`: Operation timestamp (`Date`), shared across all hooks in the same operation + +Update hooks receive the same arguments plus: + +- `oldValue`: The previous field value (null only for optional fields) ```typescript db.string().hooks({ - create: ({ user }) => user.id, - update: ({ value }) => value, + create: ({ invoker }) => invoker?.id ?? "", + update: ({ input, oldValue }) => input ?? oldValue, }); ``` -**Note:** When setting hooks at the field level, the `data` argument type is `unknown` since the field doesn't know about other fields in the type. Use type-level hooks if you need to access other fields with type safety. +Field-level hooks operate on a single field and cannot access other fields. Use type-level hooks for cross-field logic. #### Type-level Hooks -Set hooks for multiple fields at once using `db.type().hooks()`: +Set hooks across multiple fields using `db.table().hooks()`. The hook returns an object with the fields to override. When both field-level and type-level hooks exist for the same field, type-level hooks take priority. + +Create hooks receive: + +- `input`: The submitted record data. When field-level hooks or defaults exist, `input` reflects their applied results +- `invoker`: Principal performing the operation +- `now`: Operation timestamp (`Date`), shared across all hooks in the same operation + +Update hooks receive the same arguments plus: + +- `oldRecord`: The existing record (non-null) ```typescript export const customer = db - .type("Customer", { + .table("Customer", { firstName: db.string(), lastName: db.string(), fullName: db.string(), }) .hooks({ - fullName: { - create: ({ data }) => `${data.firstName} ${data.lastName}`, - update: ({ data }) => `${data.firstName} ${data.lastName}`, - }, + create: ({ input }) => ({ + fullName: `${input.firstName} ${input.lastName}`, + }), + update: ({ input, oldRecord }) => ({ + fullName: `${input.firstName ?? oldRecord.firstName} ${input.lastName ?? oldRecord.lastName}`, + }), }); ``` -**Important:** Field-level and type-level hooks cannot coexist on the same field. TypeScript will prevent this at compile time: +Use `now` to stamp several fields with the exact same instant: ```typescript -// Compile error - cannot set hooks on the same field twice -export const user = db - .type("User", { - name: db.string().hooks({ create: ({ data }) => data.firstName }), // Field-level - }) - .hooks({ - name: { create: ({ data }) => data.lastName }, // Type-level - ERROR - }); - -// OK - set hooks on different fields -export const user = db - .type("User", { - firstName: db.string().hooks({ create: () => "John" }), // Field-level on firstName - lastName: db.string(), +export const order = db + .table("Order", { + createdAt: db.datetime(), + updatedAt: db.datetime(), }) .hooks({ - lastName: { create: () => "Doe" }, // Type-level on lastName + create: ({ now }) => ({ createdAt: now, updatedAt: now }), + update: ({ now }) => ({ updatedAt: now }), }); ``` @@ -344,13 +360,7 @@ export const user = db ### Validation -Add validation rules to fields. Validators receive three arguments (executed after hooks and built-in type validation): - -- `value`: Field value after hook transformation -- `data`: Entire record data after hook transformations (for accessing other field values) -- `user`: User performing the operation - -Validators return `true` for success, `false` for failure. Use array form `[validator, errorMessage]` for custom error messages. +Add validation rules to fields. Validators run after hooks. **Note:** Custom validators run only when built-in type validation succeeds, so `value` always has the field's declared type. For array fields, the validator is called once with the complete array, not per element: @@ -361,55 +371,48 @@ db.string({ array: true }).validate(({ value }) => value.length >= 2); #### Field-level Validation -Set validators directly on individual fields: +Set validators directly on individual fields. Each validator receives `{ value }` (the field value after hooks) and returns an error message string to fail, or void to pass: ```typescript db.string().validate( - ({ value }) => value.includes("@"), - [({ value }) => value.length >= 5, "Email must be at least 5 characters"], + ({ value }) => (value.includes("@") ? undefined : "Must contain @"), + ({ value }) => (value.length >= 5 ? undefined : "Must be at least 5 characters"), ); ``` #### Type-level Validation -Set validators for multiple fields at once using `db.type().validate()`: +Set a validator across all fields using `db.table().validate()`. The validator receives `{ newRecord, oldRecord, invoker }` and an `issues()` callback to report errors per field: ```typescript export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), }) - .validate({ - name: [({ value }) => value.length > 5, "Name must be longer than 5 characters"], - email: [ - ({ value }) => value.includes("@"), - [({ value }) => value.length >= 5, "Email must be at least 5 characters"], - ], + .validate(({ newRecord }, issues) => { + if (newRecord.name.length <= 5) { + issues("name", "Name must be longer than 5 characters"); + } + if (!newRecord.email.includes("@")) { + issues("email", "Must contain @"); + } }); ``` -**Important:** Field-level and type-level validation cannot coexist on the same field. TypeScript will prevent this at compile time: +### Defaults + +Set a default value for a required field on create. The field becomes optional in the create input — the default fills in when no value is provided: ```typescript -// Compile error - cannot set validation on the same field twice -export const user = db - .type("User", { - name: db.string().validate(({ value }) => value.length > 0), // Field-level - }) - .validate({ - name: [({ value }) => value.length < 100, "Too long"], // Type-level - ERROR - }); +db.int().default(0); +db.string().default("pending"); +``` -// OK - set validation on different fields -export const user = db - .type("User", { - name: db.string().validate(({ value }) => value.length > 0), // Field-level on name - email: db.string(), - }) - .validate({ - email: [({ value }) => value.includes("@"), "Invalid email"], // Type-level on email - }); +For datetime/date/time fields, pass `"now"` to use the operation timestamp: + +```typescript +db.datetime().default("now"); ``` **Note:** `.validate()` can only be called once on a type. Duplicate type-level calls fail at compile time and throw at runtime. @@ -437,12 +440,14 @@ db.string().serial({ ### Common Fields ```typescript -export const user = db.type("User", { +export const user = db.table("User", { name: db.string(), ...db.fields.timestamps(), }); ``` +`db.fields.timestamps()` adds non-null `createdAt` and `updatedAt` datetime fields. Both fields are populated when a record is created; provided values are preserved so seed data can use historical timestamps. `updatedAt` is also refreshed automatically when a record is updated. + ## Type Modifiers Type builder methods that set one type-level configuration can be called only once on the same type. Duplicate calls fail at compile time and throw at runtime. This applies to `.description()`, `.hooks()`, `.validate()`, `.features()`, `.indexes()`, `.files()`, `.permission()`, and `.gqlPermission()`. @@ -464,7 +469,7 @@ if (enableFiles) { ### Composite Indexes ```typescript -db.type("User", { +db.table("User", { firstName: db.string(), lastName: db.string(), }).indexes({ @@ -477,7 +482,7 @@ db.type("User", { ### File Fields ```typescript -db.type("User", { +db.table("User", { name: db.string(), }).files({ avatar: "profile image", @@ -487,7 +492,7 @@ db.type("User", { ### Features ```typescript -db.type("User", { +db.table("User", { name: db.string(), }).features({ aggregation: true, @@ -500,7 +505,7 @@ db.type("User", { Enable event publishing for a type to trigger executors on record changes: ```typescript -db.type("User", { +db.table("User", { name: db.string(), }).features({ publishEvents: true, @@ -511,7 +516,7 @@ db.type("User", { - When `publishEvents: true`, record creation/update/deletion events are published - When not specified, it is **automatically set to `true`** if an executor uses this type with `recordCreatedTrigger`, `recordUpdatedTrigger`, or `recordDeletedTrigger` -- When explicitly set to `false` while an executor uses this type, an error is thrown during `tailor apply` +- When explicitly set to `false` while an executor uses this type, an error is thrown during `tailor deploy` **Use cases:** @@ -519,7 +524,7 @@ db.type("User", { ```typescript // publishEvents is automatically enabled because an executor uses this type - export const order = db.type("Order", { + export const order = db.table("Order", { status: db.string(), }); @@ -533,7 +538,7 @@ db.type("User", { 2. **Manual enable**: Enable event publishing for external consumers or debugging ```typescript - db.type("AuditLog", { + db.table("AuditLog", { action: db.string(), }).features({ publishEvents: true, // Enable even without executor triggers @@ -543,7 +548,7 @@ db.type("User", { 3. **Explicit disable**: Disable event publishing for a type that doesn't need it (error if executor uses it) ```typescript - db.type("TempData", { + db.table("TempData", { data: db.string(), }).features({ publishEvents: false, // Explicitly disable @@ -601,15 +606,15 @@ Extract subsets of fields from a `TailorDBType` for reuse in resolvers, executor Select specific fields and optionally modify their properties: ```typescript -const user = db.type("User", { +const user = db.table("User", { id: db.uuid(), name: db.string(), email: db.string().unique(), ...db.fields.timestamps(), }); -// Pick id and createdAt, making them optional -user.pickFields(["id", "createdAt"], { optional: true }); +// Pick id, createdAt, and updatedAt, making them optional +user.pickFields(["id", "createdAt", "updatedAt"], { optional: true }); ``` Available options: @@ -626,8 +631,8 @@ Available options: Return all fields except the specified ones: ```typescript -// All fields except id and createdAt -user.omitFields(["id", "createdAt"]); +// All fields except id, createdAt, and updatedAt +user.omitFields(["id", "createdAt", "updatedAt"]); ``` #### Common Pattern: Input Schema Composition @@ -642,9 +647,9 @@ export default createResolver({ name: "createUser", operation: "mutation", input: { - // id/createdAt are optional (auto-generated), other fields are required - ...user.pickFields(["id", "createdAt"], { optional: true }), - ...user.omitFields(["id", "createdAt"]), + // id/createdAt/updatedAt are optional (auto-generated), other fields are required + ...user.pickFields(["id", "createdAt", "updatedAt"], { optional: true }), + ...user.omitFields(["id", "createdAt", "updatedAt"]), }, output: t.object({ id: t.uuid() }), body: async (context) => { @@ -661,8 +666,8 @@ import { t } from "@tailor-platform/sdk"; import { invoice } from "../../tailordb/invoice"; const schemaType = t.object({ - ...invoice.pickFields(["id", "createdAt"], { optional: true }), - ...invoice.omitFields(["id", "createdAt", "invoiceNumber", "sequentialId"]), + ...invoice.pickFields(["id", "createdAt", "updatedAt"], { optional: true }), + ...invoice.omitFields(["id", "createdAt", "updatedAt", "invoiceNumber", "sequentialId"]), }); ``` @@ -675,7 +680,7 @@ Configure Permission and GQLPermission. For details, see the [TailorDB Permissio `generate`/`deploy` reject a type that has no `.permission()`, or no `.gqlPermission()` while GraphQL operations are enabled for it (see [GraphQL Operations](#graphql-operations) above). Disable GraphQL exposure entirely with `.features({ gqlOperations: { create: false, update: false, delete: false, read: false } })` if a type only needs record-level permission. ```typescript -db.type("User", { +db.table("User", { name: db.string(), role: db.enum(["admin", "user"]).index(), }) @@ -705,7 +710,7 @@ import { unsafeAllowAllGqlPermission, } from "@tailor-platform/sdk"; -db.type("User", { +db.table("User", { name: db.string(), }) .permission(unsafeAllowAllTypePermission) @@ -716,6 +721,6 @@ db.type("User", { ## Migrations -When you change a TailorDB type definition, the SDK can generate a migration that captures the diff and, for breaking changes, runs a data transformation script during `tailor-sdk deploy`. See the [TailorDB Migrations guide](./tailordb-migration.md) for the full workflow, configuration, supported change types, team coordination, and troubleshooting. +When you change a TailorDB type definition, the SDK can generate a migration that captures the diff and, for breaking changes, runs a data transformation script during `tailor deploy`. See the [TailorDB Migrations guide](./tailordb-migration.md) for the full workflow, configuration, supported change types, team coordination, and troubleshooting. For the CLI command reference, see [`tailordb migration`](../cli/tailordb.md#tailordb-migration). diff --git a/packages/sdk/docs/services/workflow.md b/packages/sdk/docs/services/workflow.md index b50cb17ff..1760a4f50 100644 --- a/packages/sdk/docs/services/workflow.md +++ b/packages/sdk/docs/services/workflow.md @@ -10,7 +10,7 @@ Workflows provide: - Durable execution with automatic state management - Resume capabilities from failure points - Access to TailorDB via Kysely query builder -- Job triggering to compose multi-step logic +- Job starting to compose multi-step logic For the official Tailor Platform documentation, see [Workflow Guide](https://docs.tailor.tech/guides/workflow). @@ -26,12 +26,12 @@ All workflow components must follow these rules: - **Job name uniqueness**: Job names must be unique across the entire project (not just within one file) - **mainJob required**: Every workflow must specify a `mainJob` -| Rule | Description | -| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `createWorkflow` result must be default export | Workflow files must export the workflow as default | -| All jobs must be named exports | Includes `mainJob` and any job triggered via `.trigger()` (even if referenced only within the same file) | -| Job `name` values must be unique | Job names must be unique across the entire project | -| `mainJob` is required | Every workflow must specify a `mainJob` | +| Rule | Description | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `createWorkflow` result must be default export | Workflow files must export the workflow as default | +| All jobs must be named exports | Includes `mainJob` and any job started via `.start()` (even if referenced only within the same file) | +| Job `name` values must be unique | Job names must be unique across the entire project | +| `mainJob` is required | Every workflow must specify a `mainJob` | ## Creating a Workflow Job @@ -92,11 +92,11 @@ export const nullJob = createWorkflowJob({ These constraints are enforced at compile time — you will get a type error if you use an unsupported type. -## Triggering Jobs +## Starting Jobs -Use `.trigger()` to start other jobs from within a job. +Use `.start()` to start other jobs from within a job. -Jobs are triggered by calling `.trigger()` on the other job object (no `deps` and no `jobs` object in the context). +Jobs are started by calling `.start()` on the other job object (no `deps` and no `jobs` object in the context). ```typescript import { createWorkflowJob } from "@tailor-platform/sdk"; @@ -105,11 +105,11 @@ import { sendNotification } from "./jobs/send-notification"; export const mainJob = createWorkflowJob({ name: "main-job", - body: async (input: { customerId: string }) => { - const customer = await fetchCustomer.trigger({ + body: (input: { customerId: string }) => { + const customer = fetchCustomer.start({ customerId: input.customerId, }); - const notification = await sendNotification.trigger({ + const notification = sendNotification.start({ message: "Order processed", recipient: customer.email, }); @@ -120,51 +120,51 @@ export const mainJob = createWorkflowJob({ ### Deterministic Execution Requirement -Workflow jobs use a **suspend/resume execution model**. When a job calls `.trigger()`, the runtime suspends the current job, executes the triggered job, and then **re-executes the calling job from the beginning** with cached results from previous triggers. +Workflow jobs use a **suspend/resume execution model**. When a job calls `.start()`, the runtime suspends the current job, executes the started job, and then **re-executes the calling job from the beginning** with cached results from previous starts. -This means that **job code must be deterministic** — every re-execution must produce the same sequence of `.trigger()` calls with the same arguments in the same order. +This means that **job code must be deterministic** — every re-execution must produce the same sequence of `.start()` calls with the same arguments in the same order. -Using `.trigger()` inside a loop works correctly, as long as the loop is deterministic: +Using `.start()` inside a loop works correctly, as long as the loop is deterministic: ```typescript // ✅ OK: deterministic loop — same calls in the same order on every execution const regions = ["us", "eu", "ap"]; for (const region of regions) { - const result = await fetchData.trigger({ region }); + const result = fetchData.start({ region }); results.push(result); } ``` ```typescript // ❌ Bad: non-deterministic — argument changes between executions -await processJob.trigger({ timestamp: Date.now() }); +processJob.start({ timestamp: Date.now() }); -// ✅ OK: call Date.now() in separated job -const timestamp = await timestampJob.trigger(); -await processJob.trigger({ timestamp }); +// ✅ OK: call Date.now() in a separate job +const timestamp = timestampJob.start(); +processJob.start({ timestamp }); ``` ```typescript // ❌ Bad: non-deterministic — external data may change between executions const items = await fetch("https://api.example.com/items").then((r) => r.json()); for (const item of items) { - await processItem.trigger({ id: item.id }); + processItem.start({ id: item.id }); } -// ✅ OK: call fetch("https://api.example.com/items").then((r) => r.json()); in separated job -const items = await fetchItemsJob.trigger(); +// ✅ OK: call fetch("https://api.example.com/items").then((r) => r.json()); in a separate job +const items = fetchItemsJob.start(); for (const item of items) { - await processItem.trigger({ id: item.id }); + processItem.start({ id: item.id }); } ``` -If the runtime detects that a `.trigger()` call at the same position has different arguments than the previous execution, it will throw an **argument hash mismatch error**. +If the runtime detects that a `.start()` call at the same position has different arguments than the previous execution, it will throw an **argument hash mismatch error**. **Guidelines:** -- Do not use non-deterministic values (random numbers, timestamps, external API responses) as `.trigger()` arguments. -- Do not use conditions that may change between executions to decide whether to call `.trigger()`. -- Any data that varies between executions should be fetched **inside the triggered job**, not passed as an argument from the calling job. +- Do not use non-deterministic values (random numbers, timestamps, external API responses) as `.start()` arguments. +- Do not use conditions that may change between executions to decide whether to call `.start()`. +- Any data that varies between executions should be fetched **inside the started job**, not passed as an argument from the calling job. ## Workflow Definition @@ -178,15 +178,15 @@ import { sendNotification } from "./jobs/send-notification"; // Jobs must be named exports export const processOrder = createWorkflowJob({ name: "process-order", - body: async (input: { customerId: string }, { env, invoker }) => { + body: (input: { customerId: string }, { env, invoker }) => { // `env` contains values from `tailor.config.ts` -> `env`. - // `invoker` is the principal running this job, overridden by `authInvoker` - // when set; `null` for anonymous calls. - // Trigger other jobs by calling .trigger() on the job object. - const customer = await fetchCustomer.trigger({ + // `invoker` is the principal running this job, or the machine user + // configured through the start `invoker` option; `null` for anonymous calls. + // Start other jobs by calling .start() on the job object. + const customer = fetchCustomer.start({ customerId: input.customerId, }); - await sendNotification.trigger({ + sendNotification.start({ message: "Order processed", recipient: customer.email, }); @@ -207,23 +207,23 @@ Wait points allow a workflow job to suspend execution and wait for an external s ### Defining Wait Points -Use `defineWaitPoint` to declare a single typed wait point: +Use `createWaitPoint` to create a single typed wait point: ```typescript -import { defineWaitPoint } from "@tailor-platform/sdk"; +import { createWaitPoint } from "@tailor-platform/sdk"; -export const approval = defineWaitPoint< +export const approval = createWaitPoint< { message: string; requestId: string }, { approved: boolean } >("approval"); ``` -For multiple wait points, use `defineWaitPoints` with a builder callback. Property names become wait point keys, and JSDoc on each property is preserved in IDE autocompletion: +For multiple wait points, use `createWaitPoints` with a builder callback. Property names become wait point keys, and JSDoc on each property is preserved in IDE autocompletion: ```typescript -import { defineWaitPoints } from "@tailor-platform/sdk"; +import { createWaitPoints } from "@tailor-platform/sdk"; -export const waitPoints = defineWaitPoints((define) => ({ +export const waitPoints = createWaitPoints((define) => ({ /** Manager approval step */ managerApproval: define<{ amount: number }, { approved: boolean }>(), /** Finance review step */ @@ -245,9 +245,9 @@ Both must be JsonValue-compatible (plain objects/arrays; no class instances or f Call `.wait()` inside a workflow job body to suspend execution: ```typescript -import { createWorkflow, createWorkflowJob, defineWaitPoint } from "@tailor-platform/sdk"; +import { createWaitPoint, createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; -export const approval = defineWaitPoint< +export const approval = createWaitPoint< { message: string; requestId: string }, { approved: boolean } >("approval"); @@ -353,7 +353,7 @@ export default createWorkflow({ ## Execution Policies -Execution policies apply a per-key concurrency cap to workflow job function dispatches. Declare them at the workspace level and pass a matching key when triggering a job; the platform serializes dispatches that resolve to the same key and suspends any that would exceed the cap until slots free up. +Execution policies apply a per-key concurrency cap to workflow job function dispatches. Declare them at the workspace level and pass a matching key when starting a job; the platform serializes dispatches that resolve to the same key and suspends any that would exceed the cap until slots free up. ### Declaring Policies @@ -401,7 +401,7 @@ An exact-key policy applies to dispatches whose runtime key equals the policy ke ### Referencing a Policy from a Workflow -Pass the runtime key through the `executionPolicyKey` option on `job.trigger()` or `tailor.workflow.startJobFunction()` (or its frozen alias `triggerJobFunction`). For exact-key policies, use `.key` directly — it's typed so only a value that came from a declared policy can be passed. For wildcard policies (`matchType: "prefix"`), there is no `.key` — call `.keyFor(suffix)` to build the concrete key. `keyFor` joins the prefix and suffix with `.` by default; override it with `separator` — the second argument to `defineWorkflowExecutionPolicies` (applies to every policy in the group), or a `def` field on a single `defineWorkflowExecutionPolicy`. +Pass the runtime key through the `executionPolicyKey` option on `job.start()` or `tailor.workflow.startJobFunction()`. For exact-key policies, use `.key` directly — it's typed so only a value that came from a declared policy can be passed. For wildcard policies (`matchType: "prefix"`), there is no `.key` — call `.keyFor(suffix)` to build the concrete key. `keyFor` joins the prefix and suffix with `.` by default; override it with `separator` — the second argument to `defineWorkflowExecutionPolicies` (applies to every policy in the group), or a `def` field on a single `defineWorkflowExecutionPolicy`. ```typescript import { createWorkflowJob } from "@tailor-platform/sdk"; @@ -413,13 +413,13 @@ export const mainJob = createWorkflowJob({ name: "main-job", body: async (input: { tenantId: string }) => { // Exact key policy: pass .key directly. - await sendNotification.trigger( + await sendNotification.start( { message: "Order processed" }, { executionPolicyKey: executionPolicies.premium.key }, ); // Wildcard policy: build the concrete key with keyFor(). - await fetchTenant.trigger( + await fetchTenant.start( { tenantId: input.tenantId }, { executionPolicyKey: executionPolicies.tenantApi.keyFor(input.tenantId) }, ); @@ -427,30 +427,30 @@ export const mainJob = createWorkflowJob({ }); ``` -The same `executionPolicyKey` option is available on `tailor.workflow.startJobFunction(name, args, options)` (or the frozen alias `tailor.workflow.triggerJobFunction`) for jobs invoked by name. +The same `executionPolicyKey` option is available on `tailor.workflow.startJobFunction(name, args, options)` for jobs invoked by name. -## Triggering a Workflow from a Resolver +## Starting a Workflow from a Resolver -You can start a workflow execution from a resolver using `workflow.trigger()`. +You can start a workflow execution from a resolver using `workflow.start()`. -- `workflow.trigger(args, options?)` returns a workflow run ID (`Promise`). -- To run with machine-user permissions, pass `{ authInvoker: "" }`. The name is type-narrowed to the machine users defined in your auth config. +- `workflow.start(args, options?)` returns a workflow run ID (`Promise`). +- To run with machine-user permissions, pass `{ invoker: "" }`. The name is type-narrowed to the machine users defined in your auth config. ```typescript import { createResolver, t } from "@tailor-platform/sdk"; import orderProcessingWorkflow from "../workflows/order-processing"; export default createResolver({ - name: "triggerOrderProcessing", + name: "startOrderProcessing", operation: "mutation", input: { orderId: t.string(), customerId: t.string(), }, body: async ({ input }) => { - const workflowRunId = await orderProcessingWorkflow.trigger( + const workflowRunId = await orderProcessingWorkflow.start( { orderId: input.orderId, customerId: input.customerId }, - { authInvoker: "manager-machine-user" }, + { invoker: "manager-machine-user" }, ); return { workflowRunId }; @@ -461,9 +461,7 @@ export default createResolver({ }); ``` -> **Deprecated:** `auth.invoker("manager-machine-user")` still works but is deprecated. Using the string form avoids importing `auth` into runtime code. - -See the full working example in the repository: [example/resolvers/triggerWorkflow.ts](https://github.com/tailor-platform/sdk/blob/main/example/resolvers/triggerWorkflow.ts). +See the full working example in the repository: [example/resolvers/startWorkflow.ts](https://github.com/tailor-platform/sdk/blob/main/example/resolvers/startWorkflow.ts). ## File Organization @@ -486,22 +484,22 @@ Manage workflows using the CLI: ```bash # List workflows -tailor-sdk workflow list +tailor workflow list # Get workflow details -tailor-sdk workflow get +tailor workflow get # Start a workflow -tailor-sdk workflow start -m -a '{"key": "value"}' +tailor workflow start -m -a '{"key": "value"}' # List executions -tailor-sdk workflow executions +tailor workflow executions # Get execution details with logs -tailor-sdk workflow executions --logs +tailor workflow executions --logs # Resume a failed execution -tailor-sdk workflow resume +tailor workflow resume ``` See [Workflow CLI Commands](../cli/workflow.md) for full documentation. diff --git a/packages/sdk/docs/testing.md b/packages/sdk/docs/testing.md index 52d837997..d1fd5eea2 100644 --- a/packages/sdk/docs/testing.md +++ b/packages/sdk/docs/testing.md @@ -11,18 +11,21 @@ Lean on unit tests for the day-to-day feedback loop — they run fast and exerci Unit-test entrypoints exposed by the SDK: -- `resolver.body({ input, user, env })` — invoke a resolver -- `workflowJob.body(input, { env })` / `workflowJob.trigger(input)` — invoke or chain a workflow job -- `executor.operation.body(args)` — invoke a function-kind executor +- `resolver.body({ input, caller, invoker, env })` — invoke a resolver +- `workflowJob.body(input, { env, invoker })` — invoke a workflow job body directly +- `workflowJob.start(input)` — chain a workflow job through the workflow runtime +- `runWorkflowLocally(workflow, args)` — run a workflow chain locally with real job bodies +- `executor.operation.body({ ...args, invoker })` — invoke a function-kind executor -Helpers under `@tailor-platform/sdk/test`: +For anonymous direct calls: -- `unauthenticatedTailorUser` — default `user` value for resolver contexts +- Pass `null` for anonymous `caller` / `invoker` context in direct unit tests. Platform API mocks under `@tailor-platform/sdk/vitest` (for use with the [`tailor-runtime` Vitest environment](#runtime-environment-emulation-beta) below): - `mockTailordb` — TailorDB query stubs and call recording - `mockWorkflow` — `tailor.workflow` job / wait / resolve mocks +- `runWorkflowLocally` — local full-chain workflow runner - `mockSecretmanager`, `mockAuthconnection`, `mockIdp`, `mockFile`, `mockIconv`, `mockAigateway` — corresponding platform API mocks For tighter alignment with the production runtime — Node.js module blocking, Web-only globals, and platform API mocks — pair the resolver helpers with the [`tailor-runtime` Vitest environment](#runtime-environment-emulation-beta) below. @@ -101,7 +104,12 @@ test("resolver queries the database", async () => { [], // COMMIT ); - const result = await resolver.body({ input: { email: "test@example.com" } }); + const result = await resolver.body({ + input: { email: "test@example.com" }, + caller: null, + invoker: null, + env: {}, + }); expect(result).toEqual({ oldAge: 30, newAge: 31 }); expect(db.executedQueries).toHaveLength(3); @@ -119,7 +127,12 @@ test("content-based mock", async () => { .returnsRowsOnce([{ id: "first" }]) .returnsRows([{ id: "later" }]); - const result = await resolver.body({ input: { userId: "1" } }); + const result = await resolver.body({ + input: { userId: "1" }, + caller: null, + invoker: null, + env: {}, + }); expect(db.executedQueries[0].query).toContain("SELECT"); }); @@ -133,13 +146,13 @@ Pass `{ onUnhandled: "error" }` to make an unmatched query fail instead of retur ### Workflow Mock -`.trigger()` runs real job bodies locally out of the box (see [Running a full workflow locally](#running-a-full-workflow-locally)). Use `job(definition)` or `workflow(definition)` to get a stable, fully typed Vitest mock for one definition: +Workflow job `.start()` calls use the platform workflow runtime. Acquire `mockWorkflow()` when you want to provide start responses with `setJobHandler` / `enqueueResult` or assert on `startedJobs`. If no response is configured, the mock throws so missing job mocks fail loudly. Use `job(definition)` or `workflow(definition)` to get a stable, fully typed Vitest mock for one definition: ```typescript import { mockWorkflow } from "@tailor-platform/sdk/vitest"; import { processPayment, validateOrder } from "./jobs"; -test("workflow triggers jobs", async () => { +test("workflow starts jobs", async () => { using wf = mockWorkflow(); const validate = wf.job(validateOrder); const payment = wf.job(processPayment); @@ -153,7 +166,7 @@ test("workflow triggers jobs", async () => { }); ``` -Unconfigured definition mocks continue to run their real implementations. The lower-level `triggerJobFunction`, `triggerWorkflow`, `resumeWorkflow`, `wait`, and `resolve` mocks and the existing `setJobHandler`, `enqueueResult`, `enqueueResults`, and call-record helpers remain available. +Unconfigured definition mocks continue to run their real implementations. The lower-level `startJobFunction`, `startWorkflow`, `resumeWorkflowExecution`, `wait`, and `resolve` mocks and the existing `setJobHandler`, `enqueueResult`, `enqueueResults`, and call-record helpers remain available. Use `waitPoint(definition)` for typed wait-point control: @@ -167,6 +180,8 @@ await approvalWaitPoint.wait({ message: "Please approve" }); expect(approvalMock.wait).toHaveBeenCalledWith({ message: "Please approve" }); ``` +Use `wf.setEnv(...)` when locally-run workflow job bodies need configuration values. Per-run `runWorkflowLocally(..., { env })` options take precedence over the mock's env. + ### SecretManager Mock ```typescript @@ -247,7 +262,7 @@ test("mock file download", async () => { }); ``` -All File operations are typed Vitest mocks: `upload`, `download`, `downloadAsBase64`, `delete`, `getMetadata`, `openDownloadStream`, `downloadStream`, and `uploadStream`. Use native methods such as `mockResolvedValueOnce` and `mockRejectedValueOnce` to model success and failure. The existing resolver, queue, and aggregate `calls` APIs remain supported. +All File operations are typed Vitest mocks: `upload`, `download`, `downloadAsBase64`, `delete`, `getMetadata`, `downloadStream`, and `uploadStream`. Use native methods such as `mockResolvedValueOnce` and `mockRejectedValueOnce` to model success and failure. The existing resolver, queue, and aggregate `calls` APIs remain supported. For `downloadStream`, configure a `FileDownloadStreamResponse` object with a `ReadableStream` body and metadata: @@ -270,28 +285,6 @@ test("mock file download stream", async () => { }); ``` -For the deprecated `openDownloadStream`, `enqueueResult` can adapt an iterable of `StreamValue` items — `metadata`, one or more `chunk` items, and a terminal `complete`. Raw `Uint8Array` / `ArrayBuffer` chunks are rejected so tests stay aligned with the platform's structured stream contract. - -```typescript -test("mock file download stream (deprecated openDownloadStream)", async () => { - using file = mockFile(); - file.enqueueResult([ - { - type: "metadata", - metadata: { contentType: "image/png", fileSize: 3, sha256sum: "abc" }, - }, - { type: "chunk", data: new Uint8Array([1, 2]), position: 0 }, - { type: "chunk", data: new Uint8Array([3]), position: 2 }, - { type: "complete" }, - ]); - - const stream = await tailordb.file.openDownloadStream("ns", "Doc", "attachment", "r-1"); - const items = []; - for await (const item of stream) items.push(item); - expect(items).toHaveLength(4); -}); -``` - ### Iconv Mock ```typescript @@ -381,7 +374,7 @@ export default defineConfig({ ## Unit Tests -Unit tests call `.body()` (or `.trigger()`) directly on a resolver, workflow job, or executor and stub any platform-provided globals they touch. +Unit tests call `.body()` (or `.start()`) directly on a resolver, workflow job, or executor and stub any platform-provided globals they touch. ### Testing Resolvers @@ -390,7 +383,6 @@ Unit tests call `.body()` (or `.trigger()`) directly on a resolver, workflow job For pure logic with no external dependencies, invoke `.body()` directly: ```typescript -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { describe, expect, test } from "vitest"; import resolver from "../src/resolver/add"; @@ -398,7 +390,8 @@ describe("add resolver", () => { test("adds two numbers", async () => { const result = await resolver.body({ input: { left: 1, right: 2 }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); expect(result).toBe(3); @@ -415,7 +408,6 @@ Stub the global `tailordb.Client` and queue raw query results in order. Best for > If you are running with the [`tailor-runtime` Vitest environment](#runtime-environment-emulation-beta), acquire `using db = mockTailordb()` to install and drive the mock `tailordb.Client` instead of `vi.stubGlobal()`. ```typescript -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import resolver from "../src/resolver/incrementUserAge"; @@ -445,7 +437,8 @@ describe("incrementUserAge resolver", () => { const result = await resolver.body({ input: { email: "test@example.com" }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); @@ -516,7 +509,6 @@ describe("decrementUserAge", () => { Pass `mock.db` to functions that take a Kysely instance. When a resolver or executor calls `getDB()` internally there is no such seam, so spy the generated `getDB` and point it at the mock: ```typescript -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { createKyselyMock } from "@tailor-platform/sdk/vitest"; import { describe, expect, test, vi } from "vitest"; import { getDB, type Namespace } from "../generated/db"; @@ -548,7 +540,8 @@ describe("upsertUsers resolver", () => { { name: "Existing", email: "exists@example.com", age: 41 }, ], }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); @@ -567,7 +560,6 @@ Reach for [`mockTailordb`](#mocking-the-tailordb-client) instead when you want t Resolvers that call `waitPoint.resolve(...)` delegate to `tailor.workflow.resolve` at runtime. With the `tailor-runtime` environment active, use `mockWorkflow().waitPoint(definition)` to invoke the callback with the payload that originally suspended the job: ```typescript -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { mockWorkflow } from "@tailor-platform/sdk/vitest"; import { describe, expect, test } from "vitest"; import { approval } from "../workflow/approval"; @@ -584,7 +576,8 @@ describe("resolveApproval resolver", () => { const result = await resolver.body({ input: { executionId: "exec-1", approved: true }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); @@ -627,7 +620,7 @@ describe("retryFailedWorkflow resolver", () => { ### Testing Executors -Function-kind executors expose their handler as `executor.operation.body(args)`. The shape of `args` is determined by the trigger — for example, `recordCreatedTrigger({ type: user })` produces `{ newRecord }` typed against the type's output. GraphQL, webhook, and workflow operation kinds are declarative and don't expose a user-authored body to test. +Function-kind executors expose their handler as `executor.operation.body(args)`. The shape of `args` is determined by the trigger — for example, `recordCreatedTrigger({ type: user })` produces `{ newRecord }` typed against the type's output, plus runtime fields such as `env`, `actor`, and `invoker`. GraphQL, webhook, and workflow operation kinds are declarative and don't expose a user-authored body to test. The `executor` template extracts shared DB access into a helper (`shared.ts`) and tests the helper directly against a mocked `tailordb.Client` (same TailorDB-mocking pattern as the resolver section). Executor handlers themselves stay thin and can be tested by spying on the helper: @@ -644,6 +637,14 @@ describe("onUserCreated executor", () => { throw new Error("expected function operation"); } await onUserCreated.operation.body({ + workspaceId: "workspace-1", + appNamespace: "app", + env: {}, + actor: null, + invoker: null, + event: "created", + rawEvent: "tailordb.type_record.created", + typeName: "User", newRecord: { id: "user-1", name: "Alice", @@ -668,11 +669,11 @@ To exercise the full chain (executor → helper → TailorDB), drop the spy and ### Testing Workflow Jobs -Workflow jobs expose the same `.body()` entrypoint as resolvers, plus `.trigger()` for calling them from another job or a test. +Workflow jobs expose the same `.body()` entrypoint as resolvers, plus `.start()` for calling them from another job or a test. #### Simple job -Call `.body()` with the input and a stub `{ env: {} }`: +Call `.body()` with the input and a stub `{ env: {}, invoker: null }`: ```typescript import { describe, expect, test } from "vitest"; @@ -680,19 +681,22 @@ import { validateOrder } from "./order-fulfillment"; describe("validateOrder", () => { test("accepts a valid order", () => { - const result = validateOrder.body({ orderId: "order-1", amount: 100 }, { env: {} }); + const result = validateOrder.body( + { orderId: "order-1", amount: 100 }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ valid: true, orderId: "order-1" }); }); test("rejects a non-positive amount", () => { - expect(() => validateOrder.body({ orderId: "order-1", amount: 0 }, { env: {} })).toThrow( - "Order amount must be positive", - ); + expect(() => + validateOrder.body({ orderId: "order-1", amount: 0 }, { env: {}, invoker: null }), + ).toThrow("Order amount must be positive"); }); }); ``` -#### Jobs that trigger other jobs +#### Jobs that start other jobs Use `mockWorkflow().job(definition)` to replace dependent jobs with deterministic results: @@ -719,7 +723,10 @@ describe("fulfillOrder", () => { confirmed: true, }); - const result = await fulfillOrder.body({ orderId: "order-1", amount: 100 }, { env: {} }); + const result = await fulfillOrder.body( + { orderId: "order-1", amount: 100 }, + { env: {}, invoker: null }, + ); expect(validate).toHaveBeenCalledWith({ orderId: "order-1", amount: 100 }); expect(result).toMatchObject({ confirmed: true, paymentStatus: "completed" }); @@ -744,7 +751,10 @@ describe("processWithApproval", () => { const approvalMock = wf.waitPoint(approval); approvalMock.wait.mockResolvedValue({ approved: true }); - const result = await processWithApproval.body({ orderId: "order-1" }, { env: {} }); + const result = await processWithApproval.body( + { orderId: "order-1" }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ orderId: "order-1", status: "approved" }); expect(approvalMock.wait).toHaveBeenCalledWith({ @@ -757,7 +767,10 @@ describe("processWithApproval", () => { using wf = mockWorkflow(); wf.waitPoint(approval).wait.mockResolvedValue({ approved: false }); - const result = await processWithApproval.body({ orderId: "order-2" }, { env: {} }); + const result = await processWithApproval.body( + { orderId: "order-2" }, + { env: {}, invoker: null }, + ); expect(result.status).toBe("rejected"); }); @@ -768,15 +781,16 @@ The lower-level `setWaitHandler` and `waitCalls` APIs remain available when one #### Running a full workflow locally -To exercise the full chain with real job bodies, just call `workflow.mainJob.trigger()` — no `mockWorkflow()` needed. Dependent jobs run their real `.body()` functions, and trigger args/results cross the same JSON boundary as the platform, so a non-serializable payload fails the test exactly as it would in production: +To exercise the full chain with real job bodies, call `runWorkflowLocally(workflow, args)`. Dependent jobs run their real `.body()` functions, and start args/results cross the same JSON boundary as the platform, so a non-serializable payload fails the test exactly as it would in production: ```typescript +import { runWorkflowLocally } from "@tailor-platform/sdk/vitest"; import { describe, expect, test } from "vitest"; import workflow from "./order-fulfillment"; describe("order-fulfillment workflow", () => { - test("mainJob.trigger() executes all jobs", async () => { - const result = await workflow.mainJob.trigger({ orderId: "order-3", amount: 300 }); + test("runWorkflowLocally() executes all jobs", async () => { + const result = await runWorkflowLocally(workflow, { orderId: "order-3", amount: 300 }); expect(result).toMatchObject({ confirmed: true, paymentStatus: "completed" }); }); @@ -785,6 +799,14 @@ describe("order-fulfillment workflow", () => { Acquire `mockWorkflow()` only when you need to override a dependent definition with `wf.job(...)` / `wf.workflow(...)` (the rest still run their real bodies), control the env via `wf.setEnv(...)`, or assert on calls. +Pass `{ env }` as the third argument when job bodies need configuration values during the local run. + +If you already acquired `mockWorkflow()`, you can also call `wf.setEnv(...)` to reuse the same env across local workflow runs. + +Like the platform runtime, the local runner re-runs the orchestrator body once per `.start()` call (N starts means N+1 passes), so any side effects outside the start results fire on every pass. Keep the body deterministic and move repeatable side effects into the started jobs. + +This helper is still a local runner. Use E2E tests when you need to verify deployed workflow scheduling, suspension, or replay behavior. + **Use when:** you want to verify orchestration end to end without the cost of a real deployment. ## End-to-End (E2E) Tests @@ -883,14 +905,13 @@ Use `startWorkflow` from the CLI helpers. It starts the workflow on the deployed import { randomUUID } from "node:crypto"; import { startWorkflow } from "@tailor-platform/sdk/cli"; import { describe, expect, test } from "vitest"; -import config from "../tailor.config"; import userProfileSync from "../src/workflow/sync-profile"; describe("user-profile-sync workflow", () => { test("executes end to end", { timeout: 180_000 }, async () => { const { executionId, wait } = await startWorkflow({ workflow: userProfileSync, - authInvoker: config.auth.invoker("admin"), + invoker: "admin", arg: { name: "workflow-test", email: `wf-${randomUUID()}@example.com`, diff --git a/packages/sdk/e2e/deploy.test.ts b/packages/sdk/e2e/deploy.test.ts index 68d1d1d0e..ffaa956e8 100644 --- a/packages/sdk/e2e/deploy.test.ts +++ b/packages/sdk/e2e/deploy.test.ts @@ -8,7 +8,7 @@ * The fix ensures services are deleted AFTER the Application is deleted. * * Prerequisites: - * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor-sdk login` + * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor login` * - TAILOR_PLATFORM_ORGANIZATION_ID environment variable must be set */ @@ -211,7 +211,7 @@ describe("E2E: Service deletion order", () => { import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), role: db.string({ optional: true }), @@ -236,7 +236,7 @@ export type user = typeof user; import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; export const extraUser = db - .type("ExtraUser", { + .table("ExtraUser", { name: db.string(), email: db.string(), }) @@ -554,7 +554,7 @@ export default defineConfig({ import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), role: db.string({ optional: true }), @@ -612,7 +612,7 @@ export default defineConfig({ import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string(), role: db.string({ optional: true }), diff --git a/packages/sdk/e2e/fixtures/migration/config.template.ts b/packages/sdk/e2e/fixtures/migration/config.template.ts index 1e08b0332..d569d729f 100644 --- a/packages/sdk/e2e/fixtures/migration/config.template.ts +++ b/packages/sdk/e2e/fixtures/migration/config.template.ts @@ -1,4 +1,5 @@ -import { defineConfig, defineAuth, defineGenerators } from "@tailor-platform/sdk"; +import { defineConfig, defineAuth, definePlugins } from "@tailor-platform/sdk"; +import { kyselyTypePlugin } from "@tailor-platform/sdk/plugin/kysely-type"; import { user } from "./tailordb/user"; const auth = defineAuth("migration-test-auth", { @@ -16,10 +17,7 @@ const auth = defineAuth("migration-test-auth", { }, }); -export const generators = defineGenerators([ - "@tailor-platform/kysely-type", - { distPath: "./generated/tailordb.ts" }, -]); +export const plugins = definePlugins(kyselyTypePlugin({ distPath: "./generated/tailordb.ts" })); export default defineConfig({ id: "dd52af75-a667-4751-806f-a6f3d16ee4c2", diff --git a/packages/sdk/e2e/fixtures/migration/tailordb/post.ts b/packages/sdk/e2e/fixtures/migration/tailordb/post.ts index 8e2cf3f3b..9158eda18 100644 --- a/packages/sdk/e2e/fixtures/migration/tailordb/post.ts +++ b/packages/sdk/e2e/fixtures/migration/tailordb/post.ts @@ -5,7 +5,7 @@ import { } from "@tailor-platform/sdk"; export const post = db - .type("Post", { + .table("Post", { title: db.string(), content: db.string({ optional: true }), }) diff --git a/packages/sdk/e2e/fixtures/migration/tailordb/user.ts b/packages/sdk/e2e/fixtures/migration/tailordb/user.ts index 474747ff9..759394c17 100644 --- a/packages/sdk/e2e/fixtures/migration/tailordb/user.ts +++ b/packages/sdk/e2e/fixtures/migration/tailordb/user.ts @@ -5,7 +5,7 @@ import { } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string().unique(), role: db.string({ optional: true }), diff --git a/packages/sdk/e2e/function-test-run.test.ts b/packages/sdk/e2e/function-test-run.test.ts index 1baaf8187..792c3ca3a 100644 --- a/packages/sdk/e2e/function-test-run.test.ts +++ b/packages/sdk/e2e/function-test-run.test.ts @@ -5,11 +5,11 @@ * different function types on the Tailor Platform server via TestExecScript API. * * Uses internal APIs directly (detectFunctionType, bundleForTestRun, executeScript) - * instead of spawning CLI subprocesses. The `apply` step still uses subprocess + * instead of spawning CLI subprocesses. The deploy step still uses subprocess * since it orchestrates multiple services. * * Prerequisites: - * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor-sdk login` + * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor login` * - TAILOR_PLATFORM_ORGANIZATION_ID environment variable must be set * - packages/sdk must be built (dist/cli/index.mjs must exist) * @@ -29,7 +29,6 @@ import { import { describe, test, expect, beforeAll } from "vitest"; import { bundleForTestRun, type ResolvedMachineUser } from "../src/cli/commands/function/bundle"; import { detectFunctionType } from "../src/cli/commands/function/detect"; -import { resolveResolverArg } from "../src/cli/commands/function/test-run"; import { initOperatorClient, type OperatorClient } from "../src/cli/shared/client"; import { loadAccessToken } from "../src/cli/shared/context"; import { executeScript, type ScriptExecutionResult } from "../src/cli/shared/script-executor"; @@ -49,7 +48,7 @@ const exampleDir = path.resolve(sdkRoot, "..", "..", "example"); let workspaceId: string; let client: OperatorClient; -let authInvoker: AuthInvoker; +let invoker: AuthInvoker; let machineUser: ResolvedMachineUser; const env = { foo: 1, bar: "hello", baz: true }; const AUTH_NAMESPACE = "my-auth"; @@ -79,8 +78,8 @@ async function runTestRun( workspaceId, name: scriptName, code, - arg: options?.arg, - invoker: authInvoker, + arg: options?.arg === undefined ? undefined : JSON.parse(options.arg), + invoker, }); return { ...result, scriptName }; } @@ -91,12 +90,8 @@ async function runTestRun( }); let resolvedArg = options?.arg; - if (detected.type === "resolver" && resolvedArg) { - if (!detected.hasInput) { - resolvedArg = undefined; - } else if (detected.inputSchema) { - resolvedArg = resolveResolverArg(resolvedArg, detected.inputSchema, machineUser, workspaceId); - } + if (detected.type === "resolver" && resolvedArg && !detected.hasInput) { + resolvedArg = undefined; } const { bundledCode, scriptName } = await bundleForTestRun({ @@ -112,8 +107,8 @@ async function runTestRun( workspaceId, name: scriptName, code: bundledCode, - arg: resolvedArg, - invoker: authInvoker, + arg: resolvedArg === undefined ? undefined : JSON.parse(resolvedArg), + invoker, }); return { ...result, scriptName, functionType: detected.type, functionName: detected.name }; @@ -138,20 +133,20 @@ describe.sequential("E2E: function test-run", () => { console.log(`Workspace created: ${workspaceId}`); trackWorkspace(workspaceId); - // Apply example config to deploy DB schema, auth, machine users - console.log("Applying example config..."); + // Deploy example config to create DB schema, auth, machine users + console.log("Deploying example config..."); execFileSync( "node", - [cliPath, "apply", "--config", "tailor.config.ts", "--workspace-id", workspaceId, "--yes"], + [cliPath, "deploy", "--config", "tailor.config.ts", "--workspace-id", workspaceId, "--yes"], { cwd: exampleDir, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, NODE_OPTIONS: "--experimental-vm-modules" }, encoding: "utf-8", - timeout: 120000, + timeout: 300000, }, ); - console.log("Apply completed."); + console.log("Deploy completed."); // Resolve machine user from API + config let machineUserId = "00000000-0000-0000-0000-000000000000"; @@ -174,7 +169,7 @@ describe.sequential("E2E: function test-run", () => { attributeList: [], }; - authInvoker = create(AuthInvokerSchema, { + invoker = create(AuthInvokerSchema, { namespace: AUTH_NAMESPACE, machineUserName: MACHINE_USER_NAME, }); @@ -183,7 +178,7 @@ describe.sequential("E2E: function test-run", () => { describe("resolver", () => { test("runs add resolver with input arguments", async () => { const result = await runTestRun("resolvers/add.ts", { - arg: '{"input":{"a":1,"b":2}}', + arg: '{"a":1,"b":2}', }); expect(result.success).toBe(true); @@ -201,11 +196,11 @@ describe.sequential("E2E: function test-run", () => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const nilUuid = "00000000-0000-0000-0000-000000000000"; - expect(parsed.user.type).toBe("machine_user"); - expect(parsed.user.workspaceId).toBe(workspaceId); - expect(parsed.user.role).toBe("MANAGER"); - expect(parsed.user.id).toMatch(uuidRegex); - expect(parsed.user.id).not.toBe(nilUuid); + expect(parsed.caller.type).toBe("machine_user"); + expect(parsed.caller.workspaceId).toBe(workspaceId); + expect(parsed.caller.role).toBe("MANAGER"); + expect(parsed.caller.id).toMatch(uuidRegex); + expect(parsed.caller.id).not.toBe(nilUuid); expect(parsed.invoker.type).toBe("machine_user"); expect(parsed.invoker.workspaceId).toBe(workspaceId); @@ -216,7 +211,7 @@ describe.sequential("E2E: function test-run", () => { test("injects environment variables into resolver", async () => { const result = await runTestRun("resolvers/env.ts", { - arg: '{"input":{"multiplier":3}}', + arg: '{"multiplier":3}', }); expect(result.success).toBe(true); @@ -229,7 +224,7 @@ describe.sequential("E2E: function test-run", () => { test("supports getDB in resolver (stepChain)", async () => { const result = await runTestRun("resolvers/stepChain.ts", { - arg: '{"input":{"user":{"name":{"first":"John","last":"Doe"}}}}', + arg: '{"user":{"name":{"first":"John","last":"Doe"}}}', }); expect(result.success).toBe(true); @@ -242,7 +237,7 @@ describe.sequential("E2E: function test-run", () => { test("inserts nested object with Date and verifies round-trip", async () => { const result = await runTestRun("resolvers/insertNestedProfileWithDate.ts", { - arg: '{"input":{"name":"Test User","email":"test@example.com"}}', + arg: '{"name":"Test User","email":"test@example.com"}', }); expect(result.success).toBe(true); @@ -257,7 +252,7 @@ describe.sequential("E2E: function test-run", () => { test("reports validation errors for invalid input", async () => { const result = await runTestRun("resolvers/add.ts", { - arg: '{"input":{"a":100,"b":2}}', + arg: '{"a":100,"b":2}', }); expect(result.success).toBe(false); @@ -316,8 +311,8 @@ describe.sequential("E2E: function test-run", () => { workspaceId, name: "add.js", code, - arg: '{"a":5,"b":7}', - invoker: authInvoker, + arg: { a: 5, b: 7 }, + invoker, }); expect(result.success).toBe(true); @@ -346,7 +341,7 @@ describe.sequential("E2E: function test-run", () => { workspaceId, name: scriptName, code: bundledCode, - invoker: authInvoker, + invoker, }); expect(result.success).toBe(false); diff --git a/packages/sdk/e2e/globalSetup.ts b/packages/sdk/e2e/globalSetup.ts index 4dbcac566..e2b74f02f 100644 --- a/packages/sdk/e2e/globalSetup.ts +++ b/packages/sdk/e2e/globalSetup.ts @@ -8,7 +8,7 @@ import * as path from "node:path"; import { initOperatorClient, type OperatorClient } from "../src/cli/shared/client"; import { loadAccessToken } from "../src/cli/shared/context"; -// e2e must authenticate as the machine user from `tailor-sdk login --machineuser`, +// e2e must authenticate as the machine user from `tailor login --machine-user`, // never as the developer's locally configured profile. delete process.env.TAILOR_PLATFORM_PROFILE; diff --git a/packages/sdk/e2e/migration.test.ts b/packages/sdk/e2e/migration.test.ts index cdf4be6a3..fd11edc6c 100644 --- a/packages/sdk/e2e/migration.test.ts +++ b/packages/sdk/e2e/migration.test.ts @@ -8,7 +8,7 @@ * - Apply with migrations * * Prerequisites: - * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor-sdk login` + * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor login` * - TAILOR_PLATFORM_ORGANIZATION_ID environment variable must be set * * Running Tests: @@ -103,17 +103,17 @@ function runGenerateCli(configPath: string, cwd: string): void { } /** - * Run the apply CLI command via subprocess + * Run the deploy CLI command via subprocess * @param {string} configPath - Path to the config file * @param {string} workspaceId - Workspace ID * @param {string} cwd - Working directory */ -function runApplyCli(configPath: string, workspaceId: string, cwd: string): void { +function runDeployCli(configPath: string, workspaceId: string, cwd: string): void { const sdkRoot = path.resolve(__dirname, ".."); const cliPath = path.join(sdkRoot, "dist", "cli", "index.mjs"); try { - execSync(`node ${cliPath} apply --config ${configPath} --workspace-id ${workspaceId} --yes`, { + execSync(`node ${cliPath} deploy --config ${configPath} --workspace-id ${workspaceId} --yes`, { cwd, stdio: ["ignore", "pipe", "pipe"], // stdin ignored, stdout/stderr piped env: { @@ -121,27 +121,27 @@ function runApplyCli(configPath: string, workspaceId: string, cwd: string): void NODE_OPTIONS: "--experimental-vm-modules", }, encoding: "utf-8", - timeout: 120000, // 120 second timeout for apply operations + timeout: 120000, // 120 second timeout for deploy operations }); // Success - output captured but not logged to keep test output clean } catch (error: unknown) { // Log error details for debugging if (error && typeof error === "object" && "stderr" in error) { - console.error("Apply CLI error:", (error as { stderr?: Buffer }).stderr?.toString()); + console.error("Deploy CLI error:", (error as { stderr?: Buffer }).stderr?.toString()); } throw error; } } /** - * Run the apply CLI command, returning the result instead of throwing so callers + * Run the deploy CLI command, returning the result instead of throwing so callers * can assert on an expected failure. * @param {string} configPath - Path to the config file * @param {string} workspaceId - Workspace ID * @param {string} cwd - Working directory - * @returns {{ ok: boolean; output: string }} Whether apply succeeded and its combined output + * @returns {{ ok: boolean; output: string }} Whether deploy succeeded and its combined output */ -function tryApplyCli( +function tryDeployCli( configPath: string, workspaceId: string, cwd: string, @@ -151,7 +151,7 @@ function tryApplyCli( try { const out = execSync( - `node ${cliPath} apply --config ${configPath} --workspace-id ${workspaceId} --yes`, + `node ${cliPath} deploy --config ${configPath} --workspace-id ${workspaceId} --yes`, { cwd, stdio: ["ignore", "pipe", "pipe"], @@ -429,7 +429,7 @@ describe.sequential("E2E: TailorDB Migrations", () => { test("applies initial migration to workspace", async () => { const configPath = createConfig(); - runApplyCli(configPath, workspaceId, tempDir); + runDeployCli(configPath, workspaceId, tempDir); // Verify: TailorDB service should exist const services = await listTailorDBServiceNames(); @@ -453,7 +453,7 @@ describe.sequential("E2E: TailorDB Migrations", () => { // Update type to add optional field updateTypeFile(`import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const user = db.type("User", { +export const user = db.table("User", { name: db.string(), email: db.string().unique(), role: db.string({ optional: true }), @@ -488,7 +488,7 @@ export type user = typeof user; test("applies non-breaking migration to workspace", async () => { const configPath = createConfig(); - runApplyCli(configPath, workspaceId, tempDir); + runDeployCli(configPath, workspaceId, tempDir); // Verify: phone field should be added to User type const fields = await getTailorDBTypeFields(tailordbName, "User"); @@ -504,7 +504,7 @@ export type user = typeof user; // Update type to add required field (breaking change) updateTypeFile(`import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const user = db.type("User", { +export const user = db.table("User", { name: db.string(), email: db.string().unique(), role: db.string({ optional: true }), @@ -552,7 +552,7 @@ export type user = typeof user; const configPath = createConfig(); - runApplyCli(configPath, workspaceId, tempDir); + runDeployCli(configPath, workspaceId, tempDir); // Verify: requiredField should be added to User type const fields = await getTailorDBTypeFields(tailordbName, "User"); @@ -616,7 +616,7 @@ export type user = typeof user; test("applies type addition to workspace", async () => { const configPath = createConfig(); - runApplyCli(configPath, workspaceId, tempDir); + runDeployCli(configPath, workspaceId, tempDir); // Verify: Post type should be added const types = await listTailorDBTypeNames(tailordbName); @@ -634,7 +634,7 @@ export type user = typeof user; // Update User type to remove requiredField updateTypeFile(`import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const user = db.type("User", { +export const user = db.table("User", { name: db.string(), email: db.string().unique(), role: db.string({ optional: true }), @@ -670,7 +670,7 @@ export type user = typeof user; test("applies field removal to workspace", async () => { const configPath = createConfig(); - runApplyCli(configPath, workspaceId, tempDir); + runDeployCli(configPath, workspaceId, tempDir); // Verify: requiredField should be removed from User type const fields = await getTailorDBTypeFields(tailordbName, "User"); @@ -686,7 +686,7 @@ export type user = typeof user; test("generates the breaking migration whose script will fail", async () => { updateTypeFile(`import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const user = db.type("User", { +export const user = db.table("User", { name: db.string(), email: db.string().unique(), role: db.string({ optional: true }), @@ -725,9 +725,9 @@ export type user = typeof user; expect(checkpointBefore).toBe(4); const configPath = createConfig(); - const result = tryApplyCli(configPath, workspaceId, tempDir); + const result = tryDeployCli(configPath, workspaceId, tempDir); - // The apply must fail for the injected reason, not an unrelated error. + // The deploy must fail for the injected reason, not an unrelated error. expect(result.ok).toBe(false); expect(result.output).toContain("simulated migration failure for rollback e2e"); @@ -755,8 +755,8 @@ export type user = typeof user; ); const configPath = createConfig(); - // No drift error: the failed apply left a consistent baseline at checkpoint 4. - runApplyCli(configPath, workspaceId, tempDir); + // No drift error: the failed deploy left a consistent baseline at checkpoint 4. + runDeployCli(configPath, workspaceId, tempDir); const fields = await getTailorDBTypeFields(tailordbName, "User"); expect(fields).toContain("loyaltyTier"); diff --git a/packages/sdk/knip.ts b/packages/sdk/knip.ts index 8377cf1ef..43bf9cc28 100644 --- a/packages/sdk/knip.ts +++ b/packages/sdk/knip.ts @@ -9,11 +9,16 @@ export default { "scripts/**", "e2e/fixtures/**", "src/cli/commands/deploy/__test_fixtures__/**", - "src/cli/commands/tailordb/erd/viewer-assets/**", - "src/cli/tsconfig-paths-hook.d.mts", + "src/cli/ts-hook.d.mts", "src/types/*.ts", "src/vitest/integration/vitest.config.ts", "zinfer.config.ts", ], + ignoreIssues: { + "src/runtime/{aigateway,authconnection,context,file,iconv,idp,secretmanager,workflow}.ts": [ + "duplicates", + ], + }, + ignoreDependencies: ["undici", "vite"], ignoreBinaries: ["knip", "publint", "actionlint"], } satisfies KnipConfig; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index cd2e1ad42..a415af0b4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@tailor-platform/sdk", - "version": "1.79.0", + "version": "2.0.0-next.8", "description": "Tailor Platform SDK - The SDK to work with Tailor Platform", "license": "MIT", "repository": { @@ -9,8 +9,7 @@ "directory": "packages/sdk" }, "bin": { - "tailor-sdk": "./dist/cli/index.mjs", - "tailor-sdk-skills": "./dist/cli/skills.mjs" + "tailor": "./dist/cli/index.mjs" }, "files": [ "CHANGELOG.md", @@ -150,11 +149,11 @@ "test:coverage": "vitest --coverage", "docs:check": "vitest run --project=unit* src/cli/docs.test.ts", "docs:update": "POLITTY_DOCS_UPDATE=true vitest run --project=unit* src/cli/docs.test.ts", - "build": "tsdown && politty generate-worker --bin dist/cli/index.mjs --program tailor-sdk --shell zsh --verify", + "build": "tsdown && politty generate-worker --bin dist/cli/index.mjs --program tailor --shell zsh --verify", "lint": "oxlint --type-aware .", - "check:public-api-jsdoc": "tsx scripts/check-public-api-jsdoc.ts", - "check:zod-isolation": "tsx scripts/check-zod-isolation.ts", - "check:import-cycles": "tsx scripts/check-import-cycles.ts", + "check:public-api-jsdoc": "node --experimental-strip-types scripts/check-public-api-jsdoc.ts", + "check:zod-isolation": "node --experimental-strip-types scripts/check-zod-isolation.ts", + "check:import-cycles": "node --experimental-strip-types scripts/check-import-cycles.ts", "lint:fix": "oxlint --type-aware . --fix", "typecheck": "tsc --noEmit", "typecheck:go": "tsgo --tsBuildInfoFile ./.tsgo.tsbuildinfo", @@ -189,18 +188,16 @@ "@toiroakr/lines-db": "0.10.1", "@toiroakr/read-multiline": "0.4.1", "@urql/core": "6.0.3", + "amaro": "1.1.10", "chalk": "5.6.2", - "chokidar": "5.0.0", "confbox": "0.2.4", "date-fns": "4.4.0", "es-toolkit": "1.49.0", "find-up-simple": "1.0.1", - "get-tsconfig": "4.14.0", "globals": "17.7.0", "graphql": "17.0.2", "inflection": "3.0.2", "kysely": "0.29.3", - "madge": "8.0.0", "mime-types": "3.0.2", "open": "11.0.0", "oxc-parser": "0.139.0", @@ -215,7 +212,6 @@ "std-env": "4.2.0", "table": "6.9.0", "ts-cron-validator": "1.1.5", - "tsx": "4.23.1", "type-fest": "5.8.0", "undici": "8.7.0", "xdg-basedir": "5.1.0", @@ -224,12 +220,12 @@ "devDependencies": { "@opentelemetry/sdk-trace-base": "2.9.0", "@tailor-platform/tailor-proto": "workspace:^", - "@types/madge": "5.0.3", "@types/mime-types": "3.0.1", "@types/node": "24.13.3", "@types/semver": "7.7.1", "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vitest/coverage-v8": "4.1.10", + "eslint-plugin-zod": "4.7.0", "oxfmt": "0.58.0", "oxlint": "1.73.0", "oxlint-tsgolint": "0.24.0", @@ -253,6 +249,6 @@ }, "engines": { "bun": ">=1.2.0", - "node": ">=22" + "node": ">=22.15.0" } } diff --git a/packages/sdk/postinstall.mjs b/packages/sdk/postinstall.mjs index 7ac8733d6..76e72bf74 100644 --- a/packages/sdk/postinstall.mjs +++ b/packages/sdk/postinstall.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node import { existsSync } from "node:fs"; -import { register } from "node:module"; import { dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { findUpSync } from "find-up-simple"; @@ -12,9 +11,9 @@ const DEFAULT_CONFIG_FILENAME = "tailor.config.ts"; async function install() { const cwd = process.env.INIT_CWD || process.cwd(); - // Skip if running in the tailor-sdk package itself + // Skip if running in the @tailor-platform/sdk package itself if (cwd === __dirname || cwd === resolve(__dirname, "..", "..")) { - console.log("⚠️ Skipping postinstall in tailor-sdk package itself"); + console.log("⚠️ Skipping postinstall in @tailor-platform/sdk package itself"); return; } @@ -22,8 +21,8 @@ async function install() { // Try to find and load the user's tailor.config.ts // Priority: env/config > search parent directories - const configPath = process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH - ? resolve(cwd, process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH) + const configPath = process.env.TAILOR_CONFIG_PATH + ? resolve(cwd, process.env.TAILOR_CONFIG_PATH) : findUpSync(DEFAULT_CONFIG_FILENAME, { cwd }); if (!configPath || !existsSync(configPath)) { @@ -34,7 +33,6 @@ async function install() { try { const configDir = dirname(configPath); process.chdir(configDir); - register("tsx", import.meta.url, { data: {} }); const { generateUserTypes, loadConfig } = await import( pathToFileURL(resolve(__dirname, "dist", "cli", "lib.mjs")).href diff --git a/packages/sdk/scripts/build-entries.mjs b/packages/sdk/scripts/build-entries.mjs index 551f2d56e..33f20e8e9 100644 --- a/packages/sdk/scripts/build-entries.mjs +++ b/packages/sdk/scripts/build-entries.mjs @@ -2,7 +2,6 @@ export const entry = [ "src/configure/index.ts", "src/cli/index.ts", "src/cli/lib.ts", - "src/cli/skills.ts", "src/utils/test/index.ts", "src/kysely/index.ts", "src/plugin/index.ts", diff --git a/packages/sdk/scripts/check-import-cycles.ts b/packages/sdk/scripts/check-import-cycles.ts index 24f9c572f..917d4b630 100644 --- a/packages/sdk/scripts/check-import-cycles.ts +++ b/packages/sdk/scripts/check-import-cycles.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env -S pnpm exec tsx // Verify the src/ module graph is acyclic — including type-only edges. // // oxlint's import/no-cycle (ignoreTypes: false) already rejects cycles formed diff --git a/packages/sdk/scripts/check-public-api-jsdoc.ts b/packages/sdk/scripts/check-public-api-jsdoc.ts index 6871035d1..38e2d53d8 100644 --- a/packages/sdk/scripts/check-public-api-jsdoc.ts +++ b/packages/sdk/scripts/check-public-api-jsdoc.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env -S pnpm exec tsx // Verify every public API export has JSDoc. // // "Public API" is derived from package.json#exports — each `types` entry diff --git a/packages/sdk/scripts/check-zod-isolation.ts b/packages/sdk/scripts/check-zod-isolation.ts index e3c3b2b45..85599bc3e 100644 --- a/packages/sdk/scripts/check-zod-isolation.ts +++ b/packages/sdk/scripts/check-zod-isolation.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env -S pnpm exec tsx // Verify zod stays isolated to the CLI entry point. // // zinfer exists so that user-facing entry points never depend on zod: diff --git a/packages/sdk/scripts/perf/features/executor-record.ts b/packages/sdk/scripts/perf/features/executor-record.ts index 2cdc2a01a..e5f392c43 100644 --- a/packages/sdk/scripts/perf/features/executor-record.ts +++ b/packages/sdk/scripts/perf/features/executor-record.ts @@ -12,7 +12,7 @@ import { db, } from "../../../src/configure"; -const dummyType = db.type("DummyType", { name: db.string() }); +const dummyType = db.table("DummyType", { name: db.string() }); export const executor0 = createExecutor({ name: "executor0", diff --git a/packages/sdk/scripts/perf/features/tailordb-basic.ts b/packages/sdk/scripts/perf/features/tailordb-basic.ts index 69421e307..7bca1c010 100644 --- a/packages/sdk/scripts/perf/features/tailordb-basic.ts +++ b/packages/sdk/scripts/perf/features/tailordb-basic.ts @@ -6,7 +6,7 @@ */ import { db } from "../../../src/configure"; -export const type0 = db.type("Type0", { +export const type0 = db.table("Type0", { stringField: db.string(), intField: db.int(), boolField: db.bool(), @@ -17,7 +17,7 @@ export const type0 = db.type("Type0", { timeField: db.time(), }); -export const type1 = db.type("Type1", { +export const type1 = db.table("Type1", { stringField: db.string(), intField: db.int(), boolField: db.bool(), @@ -28,7 +28,7 @@ export const type1 = db.type("Type1", { timeField: db.time(), }); -export const type2 = db.type("Type2", { +export const type2 = db.table("Type2", { stringField: db.string(), intField: db.int(), boolField: db.bool(), @@ -39,7 +39,7 @@ export const type2 = db.type("Type2", { timeField: db.time(), }); -export const type3 = db.type("Type3", { +export const type3 = db.table("Type3", { stringField: db.string(), intField: db.int(), boolField: db.bool(), @@ -50,7 +50,7 @@ export const type3 = db.type("Type3", { timeField: db.time(), }); -export const type4 = db.type("Type4", { +export const type4 = db.table("Type4", { stringField: db.string(), intField: db.int(), boolField: db.bool(), @@ -61,7 +61,7 @@ export const type4 = db.type("Type4", { timeField: db.time(), }); -export const type5 = db.type("Type5", { +export const type5 = db.table("Type5", { stringField: db.string(), intField: db.int(), boolField: db.bool(), @@ -72,7 +72,7 @@ export const type5 = db.type("Type5", { timeField: db.time(), }); -export const type6 = db.type("Type6", { +export const type6 = db.table("Type6", { stringField: db.string(), intField: db.int(), boolField: db.bool(), @@ -83,7 +83,7 @@ export const type6 = db.type("Type6", { timeField: db.time(), }); -export const type7 = db.type("Type7", { +export const type7 = db.table("Type7", { stringField: db.string(), intField: db.int(), boolField: db.bool(), @@ -94,7 +94,7 @@ export const type7 = db.type("Type7", { timeField: db.time(), }); -export const type8 = db.type("Type8", { +export const type8 = db.table("Type8", { stringField: db.string(), intField: db.int(), boolField: db.bool(), @@ -105,7 +105,7 @@ export const type8 = db.type("Type8", { timeField: db.time(), }); -export const type9 = db.type("Type9", { +export const type9 = db.table("Type9", { stringField: db.string(), intField: db.int(), boolField: db.bool(), diff --git a/packages/sdk/scripts/perf/features/tailordb-enum.ts b/packages/sdk/scripts/perf/features/tailordb-enum.ts index 9b0c94698..a5fdd4e54 100644 --- a/packages/sdk/scripts/perf/features/tailordb-enum.ts +++ b/packages/sdk/scripts/perf/features/tailordb-enum.ts @@ -5,70 +5,70 @@ */ import { db } from "../../../src/configure"; -export const type0 = db.type("Type0", { +export const type0 = db.table("Type0", { name: db.string(), status: db.enum(["ACTIVE", "INACTIVE", "PENDING", "ARCHIVED"]), priority: db.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]), category: db.enum(["A", "B", "C", "D", "E"]), }); -export const type1 = db.type("Type1", { +export const type1 = db.table("Type1", { name: db.string(), status: db.enum(["ACTIVE", "INACTIVE", "PENDING", "ARCHIVED"]), priority: db.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]), category: db.enum(["A", "B", "C", "D", "E"]), }); -export const type2 = db.type("Type2", { +export const type2 = db.table("Type2", { name: db.string(), status: db.enum(["ACTIVE", "INACTIVE", "PENDING", "ARCHIVED"]), priority: db.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]), category: db.enum(["A", "B", "C", "D", "E"]), }); -export const type3 = db.type("Type3", { +export const type3 = db.table("Type3", { name: db.string(), status: db.enum(["ACTIVE", "INACTIVE", "PENDING", "ARCHIVED"]), priority: db.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]), category: db.enum(["A", "B", "C", "D", "E"]), }); -export const type4 = db.type("Type4", { +export const type4 = db.table("Type4", { name: db.string(), status: db.enum(["ACTIVE", "INACTIVE", "PENDING", "ARCHIVED"]), priority: db.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]), category: db.enum(["A", "B", "C", "D", "E"]), }); -export const type5 = db.type("Type5", { +export const type5 = db.table("Type5", { name: db.string(), status: db.enum(["ACTIVE", "INACTIVE", "PENDING", "ARCHIVED"]), priority: db.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]), category: db.enum(["A", "B", "C", "D", "E"]), }); -export const type6 = db.type("Type6", { +export const type6 = db.table("Type6", { name: db.string(), status: db.enum(["ACTIVE", "INACTIVE", "PENDING", "ARCHIVED"]), priority: db.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]), category: db.enum(["A", "B", "C", "D", "E"]), }); -export const type7 = db.type("Type7", { +export const type7 = db.table("Type7", { name: db.string(), status: db.enum(["ACTIVE", "INACTIVE", "PENDING", "ARCHIVED"]), priority: db.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]), category: db.enum(["A", "B", "C", "D", "E"]), }); -export const type8 = db.type("Type8", { +export const type8 = db.table("Type8", { name: db.string(), status: db.enum(["ACTIVE", "INACTIVE", "PENDING", "ARCHIVED"]), priority: db.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]), category: db.enum(["A", "B", "C", "D", "E"]), }); -export const type9 = db.type("Type9", { +export const type9 = db.table("Type9", { name: db.string(), status: db.enum(["ACTIVE", "INACTIVE", "PENDING", "ARCHIVED"]), priority: db.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]), diff --git a/packages/sdk/scripts/perf/features/tailordb-hooks.ts b/packages/sdk/scripts/perf/features/tailordb-hooks.ts index 4247896b3..d3d78ed28 100644 --- a/packages/sdk/scripts/perf/features/tailordb-hooks.ts +++ b/packages/sdk/scripts/perf/features/tailordb-hooks.ts @@ -5,61 +5,61 @@ */ import { db } from "../../../src/configure"; -export const type0 = db.type("Type0", { +export const type0 = db.table("Type0", { name: db.string().hooks({ create: () => "default" }), createdAt: db.datetime().hooks({ create: () => new Date() }), updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), }); -export const type1 = db.type("Type1", { +export const type1 = db.table("Type1", { name: db.string().hooks({ create: () => "default" }), createdAt: db.datetime().hooks({ create: () => new Date() }), updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), }); -export const type2 = db.type("Type2", { +export const type2 = db.table("Type2", { name: db.string().hooks({ create: () => "default" }), createdAt: db.datetime().hooks({ create: () => new Date() }), updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), }); -export const type3 = db.type("Type3", { +export const type3 = db.table("Type3", { name: db.string().hooks({ create: () => "default" }), createdAt: db.datetime().hooks({ create: () => new Date() }), updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), }); -export const type4 = db.type("Type4", { +export const type4 = db.table("Type4", { name: db.string().hooks({ create: () => "default" }), createdAt: db.datetime().hooks({ create: () => new Date() }), updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), }); -export const type5 = db.type("Type5", { +export const type5 = db.table("Type5", { name: db.string().hooks({ create: () => "default" }), createdAt: db.datetime().hooks({ create: () => new Date() }), updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), }); -export const type6 = db.type("Type6", { +export const type6 = db.table("Type6", { name: db.string().hooks({ create: () => "default" }), createdAt: db.datetime().hooks({ create: () => new Date() }), updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), }); -export const type7 = db.type("Type7", { +export const type7 = db.table("Type7", { name: db.string().hooks({ create: () => "default" }), createdAt: db.datetime().hooks({ create: () => new Date() }), updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), }); -export const type8 = db.type("Type8", { +export const type8 = db.table("Type8", { name: db.string().hooks({ create: () => "default" }), createdAt: db.datetime().hooks({ create: () => new Date() }), updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), }); -export const type9 = db.type("Type9", { +export const type9 = db.table("Type9", { name: db.string().hooks({ create: () => "default" }), createdAt: db.datetime().hooks({ create: () => new Date() }), updatedAt: db.datetime({ optional: true }).hooks({ update: () => new Date() }), diff --git a/packages/sdk/scripts/perf/features/tailordb-object.ts b/packages/sdk/scripts/perf/features/tailordb-object.ts index 4d7fe6997..d863c4829 100644 --- a/packages/sdk/scripts/perf/features/tailordb-object.ts +++ b/packages/sdk/scripts/perf/features/tailordb-object.ts @@ -5,7 +5,7 @@ */ import { db } from "../../../src/configure"; -export const type0 = db.type("Type0", { +export const type0 = db.table("Type0", { name: db.string(), address: db.object({ street: db.string(), @@ -19,7 +19,7 @@ export const type0 = db.type("Type0", { }), }); -export const type1 = db.type("Type1", { +export const type1 = db.table("Type1", { name: db.string(), address: db.object({ street: db.string(), @@ -33,7 +33,7 @@ export const type1 = db.type("Type1", { }), }); -export const type2 = db.type("Type2", { +export const type2 = db.table("Type2", { name: db.string(), address: db.object({ street: db.string(), @@ -47,7 +47,7 @@ export const type2 = db.type("Type2", { }), }); -export const type3 = db.type("Type3", { +export const type3 = db.table("Type3", { name: db.string(), address: db.object({ street: db.string(), @@ -61,7 +61,7 @@ export const type3 = db.type("Type3", { }), }); -export const type4 = db.type("Type4", { +export const type4 = db.table("Type4", { name: db.string(), address: db.object({ street: db.string(), @@ -75,7 +75,7 @@ export const type4 = db.type("Type4", { }), }); -export const type5 = db.type("Type5", { +export const type5 = db.table("Type5", { name: db.string(), address: db.object({ street: db.string(), @@ -89,7 +89,7 @@ export const type5 = db.type("Type5", { }), }); -export const type6 = db.type("Type6", { +export const type6 = db.table("Type6", { name: db.string(), address: db.object({ street: db.string(), @@ -103,7 +103,7 @@ export const type6 = db.type("Type6", { }), }); -export const type7 = db.type("Type7", { +export const type7 = db.table("Type7", { name: db.string(), address: db.object({ street: db.string(), @@ -117,7 +117,7 @@ export const type7 = db.type("Type7", { }), }); -export const type8 = db.type("Type8", { +export const type8 = db.table("Type8", { name: db.string(), address: db.object({ street: db.string(), @@ -131,7 +131,7 @@ export const type8 = db.type("Type8", { }), }); -export const type9 = db.type("Type9", { +export const type9 = db.table("Type9", { name: db.string(), address: db.object({ street: db.string(), diff --git a/packages/sdk/scripts/perf/features/tailordb-optional.ts b/packages/sdk/scripts/perf/features/tailordb-optional.ts index af2c38dd6..9bb44df3e 100644 --- a/packages/sdk/scripts/perf/features/tailordb-optional.ts +++ b/packages/sdk/scripts/perf/features/tailordb-optional.ts @@ -5,7 +5,7 @@ */ import { db } from "../../../src/configure"; -export const type0 = db.type("Type0", { +export const type0 = db.table("Type0", { requiredString: db.string(), optionalString: db.string({ optional: true }), optionalInt: db.int({ optional: true }), @@ -13,7 +13,7 @@ export const type0 = db.type("Type0", { optionalDate: db.date({ optional: true }), }); -export const type1 = db.type("Type1", { +export const type1 = db.table("Type1", { requiredString: db.string(), optionalString: db.string({ optional: true }), optionalInt: db.int({ optional: true }), @@ -21,7 +21,7 @@ export const type1 = db.type("Type1", { optionalDate: db.date({ optional: true }), }); -export const type2 = db.type("Type2", { +export const type2 = db.table("Type2", { requiredString: db.string(), optionalString: db.string({ optional: true }), optionalInt: db.int({ optional: true }), @@ -29,7 +29,7 @@ export const type2 = db.type("Type2", { optionalDate: db.date({ optional: true }), }); -export const type3 = db.type("Type3", { +export const type3 = db.table("Type3", { requiredString: db.string(), optionalString: db.string({ optional: true }), optionalInt: db.int({ optional: true }), @@ -37,7 +37,7 @@ export const type3 = db.type("Type3", { optionalDate: db.date({ optional: true }), }); -export const type4 = db.type("Type4", { +export const type4 = db.table("Type4", { requiredString: db.string(), optionalString: db.string({ optional: true }), optionalInt: db.int({ optional: true }), @@ -45,7 +45,7 @@ export const type4 = db.type("Type4", { optionalDate: db.date({ optional: true }), }); -export const type5 = db.type("Type5", { +export const type5 = db.table("Type5", { requiredString: db.string(), optionalString: db.string({ optional: true }), optionalInt: db.int({ optional: true }), @@ -53,7 +53,7 @@ export const type5 = db.type("Type5", { optionalDate: db.date({ optional: true }), }); -export const type6 = db.type("Type6", { +export const type6 = db.table("Type6", { requiredString: db.string(), optionalString: db.string({ optional: true }), optionalInt: db.int({ optional: true }), @@ -61,7 +61,7 @@ export const type6 = db.type("Type6", { optionalDate: db.date({ optional: true }), }); -export const type7 = db.type("Type7", { +export const type7 = db.table("Type7", { requiredString: db.string(), optionalString: db.string({ optional: true }), optionalInt: db.int({ optional: true }), @@ -69,7 +69,7 @@ export const type7 = db.type("Type7", { optionalDate: db.date({ optional: true }), }); -export const type8 = db.type("Type8", { +export const type8 = db.table("Type8", { requiredString: db.string(), optionalString: db.string({ optional: true }), optionalInt: db.int({ optional: true }), @@ -77,7 +77,7 @@ export const type8 = db.type("Type8", { optionalDate: db.date({ optional: true }), }); -export const type9 = db.type("Type9", { +export const type9 = db.table("Type9", { requiredString: db.string(), optionalString: db.string({ optional: true }), optionalInt: db.int({ optional: true }), diff --git a/packages/sdk/scripts/perf/features/tailordb-relation.ts b/packages/sdk/scripts/perf/features/tailordb-relation.ts index 94a8affc4..6e6ce0831 100644 --- a/packages/sdk/scripts/perf/features/tailordb-relation.ts +++ b/packages/sdk/scripts/perf/features/tailordb-relation.ts @@ -5,56 +5,56 @@ */ import { db } from "../../../src/configure"; -export const targetType = db.type("TargetType", { +export const targetType = db.table("TargetType", { name: db.string(), }); -export const type0 = db.type("Type0", { +export const type0 = db.table("Type0", { name: db.string(), targetId: db.uuid().relation({ type: "n-1", toward: { type: targetType } }), }); -export const type1 = db.type("Type1", { +export const type1 = db.table("Type1", { name: db.string(), targetId: db.uuid().relation({ type: "n-1", toward: { type: targetType } }), }); -export const type2 = db.type("Type2", { +export const type2 = db.table("Type2", { name: db.string(), targetId: db.uuid().relation({ type: "n-1", toward: { type: targetType } }), }); -export const type3 = db.type("Type3", { +export const type3 = db.table("Type3", { name: db.string(), targetId: db.uuid().relation({ type: "n-1", toward: { type: targetType } }), }); -export const type4 = db.type("Type4", { +export const type4 = db.table("Type4", { name: db.string(), targetId: db.uuid().relation({ type: "n-1", toward: { type: targetType } }), }); -export const type5 = db.type("Type5", { +export const type5 = db.table("Type5", { name: db.string(), targetId: db.uuid().relation({ type: "n-1", toward: { type: targetType } }), }); -export const type6 = db.type("Type6", { +export const type6 = db.table("Type6", { name: db.string(), targetId: db.uuid().relation({ type: "n-1", toward: { type: targetType } }), }); -export const type7 = db.type("Type7", { +export const type7 = db.table("Type7", { name: db.string(), targetId: db.uuid().relation({ type: "n-1", toward: { type: targetType } }), }); -export const type8 = db.type("Type8", { +export const type8 = db.table("Type8", { name: db.string(), targetId: db.uuid().relation({ type: "n-1", toward: { type: targetType } }), }); -export const type9 = db.type("Type9", { +export const type9 = db.table("Type9", { name: db.string(), targetId: db.uuid().relation({ type: "n-1", toward: { type: targetType } }), }); diff --git a/packages/sdk/scripts/perf/features/tailordb-validate.ts b/packages/sdk/scripts/perf/features/tailordb-validate.ts index e4eacf505..e06ff2d97 100644 --- a/packages/sdk/scripts/perf/features/tailordb-validate.ts +++ b/packages/sdk/scripts/perf/features/tailordb-validate.ts @@ -5,62 +5,82 @@ */ import { db } from "../../../src/configure"; -export const type0 = db.type("Type0", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), +export const type0 = db.table("Type0", { + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); -export const type1 = db.type("Type1", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), +export const type1 = db.table("Type1", { + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); -export const type2 = db.type("Type2", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), +export const type2 = db.table("Type2", { + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); -export const type3 = db.type("Type3", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), +export const type3 = db.table("Type3", { + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); -export const type4 = db.type("Type4", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), +export const type4 = db.table("Type4", { + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); -export const type5 = db.type("Type5", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), +export const type5 = db.table("Type5", { + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); -export const type6 = db.type("Type6", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), +export const type6 = db.table("Type6", { + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); -export const type7 = db.type("Type7", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), +export const type7 = db.table("Type7", { + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); -export const type8 = db.type("Type8", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), +export const type8 = db.table("Type8", { + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); -export const type9 = db.type("Type9", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), +export const type9 = db.table("Type9", { + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); diff --git a/packages/sdk/scripts/perf/runtime-perf.sh b/packages/sdk/scripts/perf/runtime-perf.sh index 14f65b3dc..8f84af726 100644 --- a/packages/sdk/scripts/perf/runtime-perf.sh +++ b/packages/sdk/scripts/perf/runtime-perf.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Runtime performance benchmark for tailor-sdk generate and apply -d commands +# Runtime performance benchmark for tailor generate and apply -d commands # These options ensure the script fails on errors set -euo pipefail @@ -46,7 +46,7 @@ if ! pnpm generate > "${LOG_DIR}/generate-warmup.log" 2>&1; then fi echo "Warmup: Running apply (build-only)..." -if ! TAILOR_PLATFORM_SDK_BUILD_ONLY=true pnpm exec tailor-sdk deploy -c tailor.config.ts > "${LOG_DIR}/apply-warmup.log" 2>&1; then +if ! TAILOR_DEPLOY_BUILD_ONLY=true pnpm exec tailor deploy -c tailor.config.ts > "${LOG_DIR}/apply-warmup.log" 2>&1; then echo "ERROR: apply warmup failed" cat "${LOG_DIR}/apply-warmup.log" exit 1 @@ -78,7 +78,7 @@ echo "Measuring apply (build-only) command..." for i in $(seq 1 $ITERATIONS); do echo " apply iteration $i/$ITERATIONS..." START=$(get_timestamp_ms) - if ! TAILOR_PLATFORM_SDK_BUILD_ONLY=true pnpm exec tailor-sdk deploy -c tailor.config.ts > "${LOG_DIR}/apply-iter-${i}.log" 2>&1; then + if ! TAILOR_DEPLOY_BUILD_ONLY=true pnpm exec tailor deploy -c tailor.config.ts > "${LOG_DIR}/apply-iter-${i}.log" 2>&1; then echo "ERROR: apply iteration $i failed" cat "${LOG_DIR}/apply-iter-${i}.log" exit 1 diff --git a/packages/sdk/src/cli/bundler/query/query-bundler.test.ts b/packages/sdk/src/cli/bundler/query/query-bundler.test.ts index 1fa2c515e..8322b6034 100644 --- a/packages/sdk/src/cli/bundler/query/query-bundler.test.ts +++ b/packages/sdk/src/cli/bundler/query/query-bundler.test.ts @@ -12,11 +12,11 @@ describe("query-bundler", () => { `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ); fs.mkdirSync(testDir, { recursive: true }); - process.env.TAILOR_SDK_OUTPUT_DIR = testDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = testDir; }); afterAll(() => { - delete process.env.TAILOR_SDK_OUTPUT_DIR; + delete process.env.TAILOR_BUILD_OUTPUT_DIR; try { fs.rmSync(TEST_BUNDLER_BASE, { recursive: true, force: true }); } catch { @@ -63,7 +63,7 @@ describe("query-bundler", () => { }); test("writes entry files to query output directory (bundle output is in-memory only)", async () => { - const outputDir = path.join(process.env.TAILOR_SDK_OUTPUT_DIR!, "query"); + const outputDir = path.join(process.env.TAILOR_BUILD_OUTPUT_DIR!, "query"); await bundleQueryScript("sql"); await bundleQueryScript("gql"); diff --git a/packages/sdk/src/cli/cache/bundle-cache.test.ts b/packages/sdk/src/cli/cache/bundle-cache.test.ts index 4e0e7a203..7055ff2a8 100644 --- a/packages/sdk/src/cli/cache/bundle-cache.test.ts +++ b/packages/sdk/src/cli/cache/bundle-cache.test.ts @@ -444,7 +444,7 @@ describe("withCache", () => { describe("computeBundlerContextHash", () => { const baseParams = { sourceFile: "/tmp/src/resolver.ts", - serializedTriggerContext: "ctx", + extraContext: "ctx", }; test("returns the same hash for identical inputs", () => { @@ -457,7 +457,7 @@ describe("computeBundlerContextHash", () => { test.each([ ["sourceFile", {}, { sourceFile: "/tmp/src/executor.ts" }], - ["serializedTriggerContext", {}, { serializedTriggerContext: "other" }], + ["extraContext", {}, { extraContext: "other" }], ["prefix", { prefix: "ENV_A=1" }, { prefix: "ENV_B=2" }], ["bundleLogLevel", { bundleLogLevel: "DEBUG" }, { bundleLogLevel: "WARN" }], ])("returns different hash when %s differs", (_label, overrideA, overrideB) => { diff --git a/packages/sdk/src/cli/cache/bundle-cache.ts b/packages/sdk/src/cli/cache/bundle-cache.ts index 6f52f37d3..b7fac74e4 100644 --- a/packages/sdk/src/cli/cache/bundle-cache.ts +++ b/packages/sdk/src/cli/cache/bundle-cache.ts @@ -54,7 +54,7 @@ function combineHash(fileHash: string, contextHash?: string): string { type ComputeBundlerContextHashParams = { sourceFile: string; - serializedTriggerContext: string; + extraContext: string; tsconfig?: string; inlineSourcemap?: boolean; bundleLogLevel?: string; @@ -64,25 +64,19 @@ type ComputeBundlerContextHashParams = { /** * Compute a context hash for cache invalidation across bundlers. * - * Combines the source file path, serialized trigger context, tsconfig hash, - * sourcemap mode, bundle log level, and an optional prefix (e.g., serialized - * env variables) into a single SHA-256 hash. + * Combines the source file path, a caller-supplied extra context string + * (e.g. serialized workflow start-call bindings, or an HTTP adapter's method + * list), tsconfig hash, sourcemap mode, bundle log level, and an optional + * prefix (e.g., serialized env variables) into a single SHA-256 hash. * @param params - Context hash computation parameters * @returns SHA-256 hex digest of the combined context */ function computeBundlerContextHash(params: ComputeBundlerContextHashParams): string { - const { - sourceFile, - serializedTriggerContext, - tsconfig, - inlineSourcemap, - bundleLogLevel, - prefix, - } = params; + const { sourceFile, extraContext, tsconfig, inlineSourcemap, bundleLogLevel, prefix } = params; return hashContent( (prefix ?? "") + path.resolve(sourceFile) + - serializedTriggerContext + + extraContext + (tsconfig ? hashFile(tsconfig) : "") + String(inlineSourcemap ?? false) + (bundleLogLevel ?? ""), diff --git a/packages/sdk/src/cli/cache/types.ts b/packages/sdk/src/cli/cache/types.ts index 2ecf7e603..4ff752b68 100644 --- a/packages/sdk/src/cli/cache/types.ts +++ b/packages/sdk/src/cli/cache/types.ts @@ -1,10 +1,12 @@ import { z } from "zod"; +// strip unknown keys const cacheOutputFileSchema = z.object({ outputPath: z.string(), contentHash: z.string(), }); +// strip unknown keys const cacheEntrySchema = z.object({ kind: z.literal("bundle"), inputHash: z.string(), @@ -13,6 +15,7 @@ const cacheEntrySchema = z.object({ createdAt: z.string(), }); +// strip unknown keys const cacheManifestSchema = z.object({ version: z.literal(1), sdkVersion: z.string(), diff --git a/packages/sdk/src/cli/commands/api/index.ts b/packages/sdk/src/cli/commands/api/index.ts index b92d7537c..53fe2035a 100644 --- a/packages/sdk/src/cli/commands/api/index.ts +++ b/packages/sdk/src/cli/commands/api/index.ts @@ -182,7 +182,7 @@ const fieldArg = z.string().transform((val, ctx): ParsedField => { export const apiCommand = defineAppCommand({ name: "api", description: "Call Tailor Platform API endpoints directly.", - notes: `Use \`tailor-sdk api list\` to enumerate invocable methods and \`tailor-sdk api inspect \` to print an endpoint's input message tree (combine with \`--json\` for machine-readable output). + notes: `Use \`tailor api list\` to enumerate invocable methods and \`tailor api inspect \` to print an endpoint's input message tree (combine with \`--json\` for machine-readable output). The request body is inferred from the target endpoint's request schema, and commonly required fields are auto-injected so they can be omitted from \`--body\`: @@ -216,36 +216,34 @@ Use \`--field key=value\` (repeatable) to set request body fields without writin list: listCommand, inspect: inspectCommand, }, - args: z - .object({ - ...workspaceArgs, - ...configArg, - body: arg(z.string().default("{}"), { - alias: "b", - description: "Request body as JSON.", - }), - field: arg(fieldArg.array().optional(), { - alias: "f", - description: - "Set a body field as `key=value` (repeatable; dotted keys nest). Overrides --body.", - completion: { - custom: { - expand: { - dependsOn: ["endpoint"], - enumerate: ({ endpoint }) => - enumerateAllFieldCompletions(extractMethodName(endpoint ?? "")), - }, + args: z.strictObject({ + ...workspaceArgs, + ...configArg, + body: arg(z.string().default("{}"), { + alias: "b", + description: "Request body as JSON.", + }), + field: arg(fieldArg.array().optional(), { + alias: "f", + description: + "Set a body field as `key=value` (repeatable; dotted keys nest). Overrides --body.", + completion: { + custom: { + expand: { + dependsOn: ["endpoint"], + enumerate: ({ endpoint }) => + enumerateAllFieldCompletions(extractMethodName(endpoint ?? "")), }, }, - }), - endpoint: arg(z.string(), { - positional: true, - description: - "API endpoint to call (e.g., 'GetApplication' or 'tailor.v1.OperatorService/GetApplication').", - completion: { custom: { choices: listMethodChoices() } }, - }), - }) - .strict(), + }, + }), + endpoint: arg(z.string(), { + positional: true, + description: + "API endpoint to call (e.g., 'GetApplication' or 'tailor.v1.OperatorService/GetApplication').", + completion: { custom: { choices: listMethodChoices() } }, + }), + }), run: async (args) => { // Direct API calls can target any OperatorService method, including // Create/Update/Delete. Block all of them under a readonly profile rather diff --git a/packages/sdk/src/cli/commands/api/inspect.ts b/packages/sdk/src/cli/commands/api/inspect.ts index bcbbe7fe2..76ebd3aea 100644 --- a/packages/sdk/src/cli/commands/api/inspect.ts +++ b/packages/sdk/src/cli/commands/api/inspect.ts @@ -10,7 +10,7 @@ export const inspectCommand = defineAppCommand({ name: "inspect", description: "Print the input message tree of an OperatorService endpoint.", notes: - "Combine with the global `--json` flag for a machine-readable descriptor. Recursive type references and `oneof` membership are annotated. Use `tailor-sdk api list` to discover endpoint names.", + "Combine with the global `--json` flag for a machine-readable descriptor. Recursive type references and `oneof` membership are annotated. Use `tailor api list` to discover endpoint names.", examples: [ { cmd: "GetApplication", desc: "Show fields of GetApplicationRequest." }, { @@ -18,23 +18,21 @@ export const inspectCommand = defineAppCommand({ desc: "Inspect a deeply nested input with `(oneof config)` annotations.", }, ], - args: z - .object({ - endpoint: arg(z.string(), { - positional: true, - description: - "API endpoint to inspect (e.g., 'GetApplication' or 'tailor.v1.OperatorService/GetApplication').", - completion: { custom: { choices: listMethodNames() } }, - }), - }) - .strict(), + args: z.strictObject({ + endpoint: arg(z.string(), { + positional: true, + description: + "API endpoint to inspect (e.g., 'GetApplication' or 'tailor.v1.OperatorService/GetApplication').", + completion: { custom: { choices: listMethodNames() } }, + }), + }), run: (args) => { const methodName = extractMethodName(args.endpoint); const method = getMethodDescriptor(methodName); if (!method) { throw CLIError({ message: `unknown method: ${methodName}`, - suggestion: "Run `tailor-sdk api list` to see available methods.", + suggestion: "Run `tailor api list` to see available methods.", command: "api inspect", }); } diff --git a/packages/sdk/src/cli/commands/api/list.ts b/packages/sdk/src/cli/commands/api/list.ts index c4cc5c464..e8f370e30 100644 --- a/packages/sdk/src/cli/commands/api/list.ts +++ b/packages/sdk/src/cli/commands/api/list.ts @@ -8,7 +8,7 @@ export const listCommand = defineAppCommand({ description: "List all invocable OperatorService methods.", notes: "Only single-request (non-streaming) methods are listed, because the CLI issues a single JSON request and reads one JSON response.", - args: z.object({}).strict(), + args: z.strictObject({}), run: () => { const names = listMethodNames(); if (logger.jsonMode) { diff --git a/packages/sdk/src/cli/commands/api/proto-reflect.ts b/packages/sdk/src/cli/commands/api/proto-reflect.ts index 1ab5bf021..906714d38 100644 --- a/packages/sdk/src/cli/commands/api/proto-reflect.ts +++ b/packages/sdk/src/cli/commands/api/proto-reflect.ts @@ -2,7 +2,7 @@ import { ScalarType } from "@bufbuild/protobuf"; import { OperatorService } from "@tailor-platform/tailor-proto/service_pb"; import type { DescField, DescMessage, DescMethodUnary } from "@bufbuild/protobuf"; -// `tailor-sdk api` issues a single JSON POST and reads one JSON response, so +// `tailor api` issues a single JSON POST and reads one JSON response, so // only unary RPCs can be invoked. Streaming methods are filtered out of all // discovery surfaces (`api list`, `api inspect`). `OperatorService.methods` // is invariant at runtime, so we filter once and reuse — completion-script diff --git a/packages/sdk/src/cli/commands/auth/index.ts b/packages/sdk/src/cli/commands/auth/index.ts new file mode 100644 index 000000000..34d32fb14 --- /dev/null +++ b/packages/sdk/src/cli/commands/auth/index.ts @@ -0,0 +1,10 @@ +import { defineCommand } from "politty"; +import { tokenCommand } from "./token"; + +export const authCommand = defineCommand({ + name: "auth", + description: "Authentication helpers for scripts and plugins.", + subCommands: { + token: tokenCommand, + }, +}); diff --git a/packages/sdk/src/cli/commands/auth/token.ts b/packages/sdk/src/cli/commands/auth/token.ts new file mode 100644 index 000000000..f0af9cea1 --- /dev/null +++ b/packages/sdk/src/cli/commands/auth/token.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; +import { workspaceArgs } from "#/cli/shared/args"; +import { defineAppCommand } from "#/cli/shared/command"; +import { loadAccessToken } from "#/cli/shared/context"; +import { logger } from "#/cli/shared/logger"; + +export const tokenCommand = defineAppCommand({ + name: "token", + description: + "Print a valid Tailor Platform access token to stdout, refreshing it first if expired.", + args: z.strictObject({ + profile: workspaceArgs.profile, + }), + run: async ({ profile }) => { + const token = await loadAccessToken({ profile }); + logger.out(token); + }, +}); diff --git a/packages/sdk/src/cli/commands/authconnection/authorize.ts b/packages/sdk/src/cli/commands/authconnection/authorize.ts index 80deb7634..c296091e1 100644 --- a/packages/sdk/src/cli/commands/authconnection/authorize.ts +++ b/packages/sdk/src/cli/commands/authconnection/authorize.ts @@ -38,27 +38,21 @@ function randomState() { export const authorizeAuthConnectionCommand = defineAppCommand({ name: "authorize", description: "Authorize an auth connection via OAuth2 flow.", - args: z - .object({ - ...workspaceArgs, - ...connectionNameArgs, - scopes: z - .string() - .optional() - .default(defaultScopes) - .describe("OAuth2 scopes to request (comma-separated)"), - port: z.coerce - .number() - .optional() - .default(defaultPort) - .describe("Local callback server port"), - "no-browser": z - .boolean() - .optional() - .default(false) - .describe("Don't open browser automatically"), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...connectionNameArgs, + scopes: z + .string() + .optional() + .default(defaultScopes) + .describe("OAuth2 scopes to request (comma-separated)"), + port: z.coerce.number().optional().default(defaultPort).describe("Local callback server port"), + "no-browser": z + .boolean() + .optional() + .default(false) + .describe("Don't open browser automatically"), + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ @@ -185,7 +179,7 @@ export const authorizeAuthConnectionCommand = defineAppCommand({ logger.warn( `Could not start the local callback server on port ${args.port}${code ? ` (${code})` : ""}.\n` + `${portHint}\n` + - ` tailor-sdk authconnection open`, + ` tailor authconnection open`, ); reject(err); }); @@ -199,7 +193,7 @@ export const authorizeAuthConnectionCommand = defineAppCommand({ ); logger.info( `If this flow doesn't complete, you can authorize via the Console instead:\n` + - ` tailor-sdk authconnection open`, + ` tailor authconnection open`, ); if (!args["no-browser"]) { try { diff --git a/packages/sdk/src/cli/commands/authconnection/delete.ts b/packages/sdk/src/cli/commands/authconnection/delete.ts index 67887408b..94245b842 100644 --- a/packages/sdk/src/cli/commands/authconnection/delete.ts +++ b/packages/sdk/src/cli/commands/authconnection/delete.ts @@ -12,13 +12,11 @@ import { connectionNameArgs } from "./args"; export const deleteAuthConnectionCommand = defineAppCommand({ name: "delete", description: "Delete an auth connection entirely.", - args: z - .object({ - ...workspaceArgs, - ...connectionNameArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...connectionNameArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/authconnection/list.ts b/packages/sdk/src/cli/commands/authconnection/list.ts index d260e2ae4..8495a6768 100644 --- a/packages/sdk/src/cli/commands/authconnection/list.ts +++ b/packages/sdk/src/cli/commands/authconnection/list.ts @@ -36,7 +36,7 @@ function connectionInfo(connection: AuthConnection): ConnectionInfo { export const listAuthConnectionCommand = defineAppCommand({ name: "list", description: "List all auth connections.", - args: z.object({ ...workspaceArgs, ...paginationArgs() }).strict(), + args: z.strictObject({ ...workspaceArgs, ...paginationArgs() }), run: async (args) => { const accessToken = await loadAccessToken({ profile: args.profile, diff --git a/packages/sdk/src/cli/commands/authconnection/open.ts b/packages/sdk/src/cli/commands/authconnection/open.ts index d5d4d23f8..80e859e33 100644 --- a/packages/sdk/src/cli/commands/authconnection/open.ts +++ b/packages/sdk/src/cli/commands/authconnection/open.ts @@ -8,7 +8,7 @@ import { logger } from "#/cli/shared/logger"; export const openAuthConnectionCommand = defineAppCommand({ name: "open", description: "Open the auth connections page in the Tailor Platform Console.", - args: z.object({ ...workspaceArgs }).strict(), + args: z.strictObject({ ...workspaceArgs }), run: async (args) => { const workspaceId = await loadWorkspaceId({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/authconnection/revoke.ts b/packages/sdk/src/cli/commands/authconnection/revoke.ts index 7d8486791..70213a6bd 100644 --- a/packages/sdk/src/cli/commands/authconnection/revoke.ts +++ b/packages/sdk/src/cli/commands/authconnection/revoke.ts @@ -15,13 +15,11 @@ export const revokeAuthConnectionCommand = defineAppCommand({ "Revoke an auth connection's tokens (keeps the connection; use 'delete' to remove it).", notes: "Revoke invalidates the connection's active session and tokens but keeps the connection and its stored credentials, so it can be re-authorized later. Use `delete` to remove the connection entirely.", - args: z - .object({ - ...workspaceArgs, - ...connectionNameArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...connectionNameArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/crashreport/index.ts b/packages/sdk/src/cli/commands/crashreport/index.ts index 2a405e78e..dc68be178 100644 --- a/packages/sdk/src/cli/commands/crashreport/index.ts +++ b/packages/sdk/src/cli/commands/crashreport/index.ts @@ -4,7 +4,6 @@ import { sendCommand } from "./send"; export const crashReportCommand = defineCommand({ name: "crashreport", - aliases: ["crash-report"], description: "Manage crash reports.", subCommands: { list: listCommand, diff --git a/packages/sdk/src/cli/commands/crashreport/list.ts b/packages/sdk/src/cli/commands/crashreport/list.ts index 3d7d0c03b..6d307efcb 100644 --- a/packages/sdk/src/cli/commands/crashreport/list.ts +++ b/packages/sdk/src/cli/commands/crashreport/list.ts @@ -26,11 +26,9 @@ function formatCrashReportFiles(files: string[], localDir: string) { export const listCommand = defineAppCommand({ name: "list", description: "List local crash report files.", - args: z - .object({ - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...paginationArgs(), + }), run: async (args) => { const config = parseCrashReportConfig(); const jsonOutput = logger.jsonMode; diff --git a/packages/sdk/src/cli/commands/crashreport/send.test.ts b/packages/sdk/src/cli/commands/crashreport/send.test.ts index 37fd42904..c3ee42898 100644 --- a/packages/sdk/src/cli/commands/crashreport/send.test.ts +++ b/packages/sdk/src/cli/commands/crashreport/send.test.ts @@ -16,7 +16,7 @@ function makeCrashReport(overrides?: Partial): CrashReport { osRelease: "25.3.0", arch: "arm64", command: "apply", - argv: ["node", "tailor-sdk", "apply"], + argv: ["node", "tailor", "apply"], errorName: "TypeError", errorMessage: "Cannot read properties of undefined", stackTrace: diff --git a/packages/sdk/src/cli/commands/crashreport/send.ts b/packages/sdk/src/cli/commands/crashreport/send.ts index 44a08b112..18c1240ec 100644 --- a/packages/sdk/src/cli/commands/crashreport/send.ts +++ b/packages/sdk/src/cli/commands/crashreport/send.ts @@ -11,15 +11,13 @@ import type { CrashReport } from "#/cli/crashreport/report"; export const sendCommand = defineAppCommand({ name: "send", description: "Submit a crash report to help improve the SDK.", - args: z - .object({ - file: arg(z.string(), { - description: "Path to the crash report file", - required: true, - completion: { type: "file", extensions: ["log"] }, - }), - }) - .strict(), + args: z.strictObject({ + file: arg(z.string(), { + description: "Path to the crash report file", + required: true, + completion: { type: "file", extensions: ["log"] }, + }), + }), run: async (args) => { let content: string; try { diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/integration.test.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/integration.test.ts index 431e836fa..4bcbc8e94 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/integration.test.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/integration.test.ts @@ -62,7 +62,7 @@ describe("deploy command integration tests", () => { }, 120000); afterAll(() => { - delete process.env.TAILOR_SDK_OUTPUT_DIR; + delete process.env.TAILOR_BUILD_OUTPUT_DIR; vi.useRealTimers(); }); @@ -79,9 +79,6 @@ describe("deploy command integration tests", () => { ); expect(entryFiles).toEqual([]); - // Unrelated files in former bundle directories should be preserved - expect(actualFiles).toContain("resolvers/keep.txt"); - // Bundle output files should NOT exist on disk (in-memory only) const bundleOutputFiles = actualFiles.filter( (f) => f.endsWith(".js") && !f.endsWith(".entry.js"), diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/prepare.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/prepare.ts index 914d21ea5..e115a54a0 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/prepare.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/prepare.ts @@ -23,25 +23,9 @@ export async function prepareFixtures(): Promise<{ fs.rmSync(outputDir, { recursive: true }); } - for (const file of [ - "resolvers/legacy.entry.js", - "executors/legacy.entry.js", - "workflow-jobs/legacy.entry.js", - "workflow-jobs/removed-job.js", - "workflow-jobs/removed-job.js.map", - "auth-hooks/legacy.entry.js", - "http-adapters/legacy.input.entry.js", - "hooks-validate-scripts/LegacyType/tailordb-script-0.entry.ts", - "resolvers/keep.txt", - ]) { - const filePath = path.join(outputDir, file); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, "legacy bundle artifact"); - } - const configPath = path.join(fixtureDir, "tailor.config.ts"); - process.env.TAILOR_SDK_OUTPUT_DIR = outputDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = outputDir; // Generate plugin output (db.ts, enums.ts) await generate({ configPath }); diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/add.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/add.ts index 2656931ca..f3a0460b7 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/add.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/add.ts @@ -1,9 +1,9 @@ import { createResolver, t } from "@tailor-platform/sdk"; -const validators: [(a: { value: number }) => boolean, string][] = [ - [({ value }) => value >= 0, "Value must be non-negative"], - [({ value }) => value < 10, "Value must be less than 10"], -]; +const validators = [ + ({ value }: { value: number }) => (value >= 0 ? undefined : "Value must be non-negative"), + ({ value }: { value: number }) => (value < 10 ? undefined : "Value must be less than 10"), +] as const; export default createResolver({ name: "add", description: "Addition operation", diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/showInfo.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/showInfo.ts index efac35219..0407f7fe1 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/showInfo.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/showInfo.ts @@ -6,12 +6,12 @@ export default createResolver({ operation: "query", body: (context) => { return { - id: context.user.id, - type: context.user.type, - workspaceId: context.user.workspaceId, + id: context.caller?.id ?? "", + type: context.caller?.type ?? "", + workspaceId: context.caller?.workspaceId ?? "", // platform response may omit the field // oxlint-disable-next-line typescript/no-unnecessary-condition - role: (context.user.attributes?.role as string) ?? "ADMIN", + role: (context.caller?.attributes.role as string | undefined) ?? "ADMIN", }; }, output: t diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.generators-compat.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.generators-compat.ts deleted file mode 100644 index fcf2c1a1b..000000000 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.generators-compat.ts +++ /dev/null @@ -1,14 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -- test fixture runs as standalone config, needs node:path -import * as path from "node:path"; -import * as url from "node:url"; -import { defineGenerators } from "@tailor-platform/sdk"; -import config from "./tailor.config"; - -const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); -const outDir = path.join(__dirname, "generators-compat-out"); - -export default config; -export const generators = defineGenerators( - ["@tailor-platform/kysely-type", { distPath: path.join(outDir, "db.ts") }], - ["@tailor-platform/enum-constants", { distPath: path.join(outDir, "enums.ts") }], -); diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.ts index 68bd0ce06..3bec3f5ac 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.ts @@ -6,7 +6,7 @@ import { enumConstantsPlugin } from "@tailor-platform/sdk/plugin/enum-constants" import { kyselyTypePlugin } from "@tailor-platform/sdk/plugin/kysely-type"; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); -const outDir = process.env.TAILOR_SDK_OUTPUT_DIR ?? path.join(__dirname, "dist"); +const outDir = process.env.TAILOR_BUILD_OUTPUT_DIR ?? path.join(__dirname, "dist"); export default defineConfig({ // SDK-managed app id — do not edit, except when copying this config to a separate app. diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailordb/order.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailordb/order.ts index 9ba34eec4..716384f6a 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailordb/order.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailordb/order.ts @@ -6,7 +6,7 @@ import { import { user } from "./user"; export const order = db - .type("Order", { + .table("Order", { title: db.string(), amount: db.int(), userID: db.uuid().relation({ type: "n-1", toward: { type: user } }), diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailordb/user.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailordb/user.ts index 43395af90..27cd3158f 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailordb/user.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailordb/user.ts @@ -5,7 +5,7 @@ import { } from "@tailor-platform/sdk"; export const user = db - .type("User", { + .table("User", { name: db.string(), email: db.string().unique(), role: db.enum(["ADMIN", "MEMBER"]), diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/workflows/processOrder.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/workflows/processOrder.ts index 7bd682c82..13fa6532e 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/workflows/processOrder.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/workflows/processOrder.ts @@ -3,14 +3,14 @@ import { format } from "date-fns"; export const fetchDetails = createWorkflowJob({ name: "fetch-details", - body: async (input: { orderId: string }) => { + body: (input: { orderId: string }) => { return { orderId: input.orderId, status: "found" }; }, }); export const notifyUser = createWorkflowJob({ name: "notify-user", - body: async (_input: { message: string; recipient: string }) => { + body: (_input: { message: string; recipient: string }) => { const timestamp = format(new Date(), "yyyy-MM-dd HH:mm:ss"); return { sent: true, timestamp }; }, @@ -18,15 +18,15 @@ export const notifyUser = createWorkflowJob({ export const processOrder = createWorkflowJob({ name: "process-order", - body: async (input: { orderId: string; userEmail: string }) => { - const details = await fetchDetails.trigger({ orderId: input.orderId }); - // trigger return may be undefined when the upstream job produces no value + body: (input: { orderId: string; userEmail: string }) => { + const details = fetchDetails.start({ orderId: input.orderId }); + // start return may be undefined when the upstream job produces no value // oxlint-disable-next-line typescript/no-unnecessary-condition if (!details) { throw new Error(`Order ${input.orderId} not found`); } - const notification = await notifyUser.trigger({ + const notification = notifyUser.start({ message: `Order ${input.orderId} processed`, recipient: input.userEmail, }); diff --git a/packages/sdk/src/cli/commands/deploy/auth-connection.test.ts b/packages/sdk/src/cli/commands/deploy/auth-connection.test.ts index 1afb92ece..562e0042c 100644 --- a/packages/sdk/src/cli/commands/deploy/auth-connection.test.ts +++ b/packages/sdk/src/cli/commands/deploy/auth-connection.test.ts @@ -341,7 +341,7 @@ describe("applyAuthConnections", () => { await applyAuthConnections(client, result, "create-update"); expect(mockLoggerInfo).toHaveBeenCalledWith( - expect.stringContaining("tailor-sdk authconnection authorize --name new-conn"), + expect.stringContaining("tailor authconnection authorize --name new-conn"), ); }); @@ -432,7 +432,7 @@ describe("applyAuthConnections", () => { await applyAuthConnections(client, result, "create-update"); expect(mockLoggerWarn).toHaveBeenCalledWith( - expect.stringContaining("tailor-sdk authconnection authorize --name conn"), + expect.stringContaining("tailor authconnection authorize --name conn"), ); }); }); diff --git a/packages/sdk/src/cli/commands/deploy/auth-connection.ts b/packages/sdk/src/cli/commands/deploy/auth-connection.ts index 4ce6bf2ee..126422ce9 100644 --- a/packages/sdk/src/cli/commands/deploy/auth-connection.ts +++ b/packages/sdk/src/cli/commands/deploy/auth-connection.ts @@ -261,8 +261,8 @@ export async function applyAuthConnections( await client.setMetadata(create.metaRequest); logger.info( `Connection "${create.name}" was created. Authorize it with:\n` + - ` tailor-sdk authconnection authorize --name ${create.name}\n` + - `Or via the Console: tailor-sdk authconnection open`, + ` tailor authconnection authorize --name ${create.name}\n` + + `Or via the Console: tailor authconnection open`, ); }), ); @@ -272,8 +272,8 @@ export async function applyAuthConnections( if (resp.connection?.status === AuthConnection_Status.UNAUTHORIZED) { logger.warn( `Connection "${replace.name}" requires re-authorization. Authorize with:\n` + - ` tailor-sdk authconnection authorize --name ${replace.name}\n` + - `Or via the Console: tailor-sdk authconnection open`, + ` tailor authconnection authorize --name ${replace.name}\n` + + `Or via the Console: tailor authconnection open`, ); } await client.setMetadata(replace.metaRequest); diff --git a/packages/sdk/src/cli/commands/deploy/auth-invoker.test.ts b/packages/sdk/src/cli/commands/deploy/auth-invoker.test.ts deleted file mode 100644 index b79b5d4e9..000000000 --- a/packages/sdk/src/cli/commands/deploy/auth-invoker.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { normalizeAuthInvoker } from "./auth-invoker"; - -describe("normalizeAuthInvoker", () => { - test("returns undefined when authInvoker is undefined", () => { - expect(normalizeAuthInvoker(undefined, "my-auth", "ctx")).toBeUndefined(); - }); - - test("expands a string to the object form using authNamespace", () => { - expect(normalizeAuthInvoker("kiosk", "my-auth", "ctx")).toEqual({ - namespace: "my-auth", - machineUserName: "kiosk", - }); - }); - - test("passes through object form unchanged", () => { - expect( - normalizeAuthInvoker({ namespace: "other-auth", machineUserName: "kiosk" }, "my-auth", "ctx"), - ).toEqual({ - namespace: "other-auth", - machineUserName: "kiosk", - }); - }); - - test("throws when string authInvoker is given without an authNamespace", () => { - expect(() => normalizeAuthInvoker("kiosk", undefined, 'Resolver "foo"')).toThrow( - /Resolver "foo".*no Auth service is configured/, - ); - }); -}); diff --git a/packages/sdk/src/cli/commands/deploy/auth-invoker.ts b/packages/sdk/src/cli/commands/deploy/auth-invoker.ts deleted file mode 100644 index 6a494dcb4..000000000 --- a/packages/sdk/src/cli/commands/deploy/auth-invoker.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { AuthInvoker } from "#/types/auth.generated"; - -/** - * Normalize an authInvoker value to the object form required by the proto payload. - * - * Accepts either: - * - `undefined` — returns undefined - * - a plain string (machine user name) — expands to `{ namespace, machineUserName }` using `authNamespace` - * - an object `{ namespace, machineUserName }` — returned as-is - * @param authInvoker - String machine user name or object form - * @param authNamespace - Auth service namespace (required when authInvoker is a string) - * @param context - Contextual label used in error messages (e.g. `resolver "foo"`) - * @returns Object form of auth invoker, or undefined - */ -export function normalizeAuthInvoker( - authInvoker: string | AuthInvoker | undefined, - authNamespace: string | undefined, - context: string, -): { namespace: string; machineUserName: string } | undefined { - if (authInvoker === undefined) return undefined; - if (typeof authInvoker === "string") { - if (!authNamespace) { - throw new Error( - `${context} uses a string authInvoker ("${authInvoker}"), but no Auth service is configured. ` + - `Configure an Auth service or use the object form { namespace, machineUserName }.`, - ); - } - return { namespace: authNamespace, machineUserName: authInvoker }; - } - return authInvoker; -} diff --git a/packages/sdk/src/cli/commands/deploy/config-id-ci-guard.test.ts b/packages/sdk/src/cli/commands/deploy/config-id-ci-guard.test.ts index e8beaae77..025029a0f 100644 --- a/packages/sdk/src/cli/commands/deploy/config-id-ci-guard.test.ts +++ b/packages/sdk/src/cli/commands/deploy/config-id-ci-guard.test.ts @@ -46,7 +46,7 @@ describe("ensureConfigIdForDeploy", () => { const { ensureConfigIdForDeploy } = await load(true); await expect( ensureConfigIdForDeploy({ configPath: filePath, dryRun: false, buildOnly: false }), - ).rejects.toThrow(/missing an 'id'|tailor-sdk setup|apply/); + ).rejects.toThrow(/missing an 'id'|tailor setup|deploy/); // Must not have injected anything in CI. expect(await fs.promises.readFile(filePath, "utf-8")).toBe(configWithoutId); }); @@ -100,8 +100,8 @@ export default defineConfig({ ).rejects.toThrow(/must be a UUID/); }); - test("CI + missing id + TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION: injects an id", async () => { - vi.stubEnv("TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION", "true"); + test("CI + missing id + TAILOR_CI_ALLOW_ID_INJECTION: injects an id", async () => { + vi.stubEnv("TAILOR_CI_ALLOW_ID_INJECTION", "true"); const filePath = await writeConfig(configWithoutId); const { ensureConfigIdForDeploy } = await load(true); await ensureConfigIdForDeploy({ configPath: filePath, dryRun: false, buildOnly: false }); diff --git a/packages/sdk/src/cli/commands/deploy/config-id-injector.ts b/packages/sdk/src/cli/commands/deploy/config-id-injector.ts index d0ced400e..4065acaf9 100644 --- a/packages/sdk/src/cli/commands/deploy/config-id-injector.ts +++ b/packages/sdk/src/cli/commands/deploy/config-id-injector.ts @@ -169,7 +169,7 @@ async function assertConfigIdInCI(configPath: string): Promise { throw new Error( `tailor.config.ts is missing an 'id'. CI does not auto-generate one ` + `(each run would be treated as a separate app and break resource ownership). ` + - `Run 'tailor-sdk setup' or 'tailor-sdk apply' locally and commit the injected id.`, + `Run 'tailor setup' or 'tailor deploy' locally and commit the injected id.`, ); } // Keep CI and local behavior aligned: ensureConfigId() enforces the same @@ -190,7 +190,7 @@ async function assertConfigIdInCI(configPath: string): Promise { * ownership. CI dry-runs (plan) perform the same check read-only, so a * forgotten id fails at PR time instead of at deploy. Ephemeral pipelines that * intentionally deploy a fresh app per run (such as e2e harnesses) can opt - * back into injection with `TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION=true`. + * back into injection with `TAILOR_CI_ALLOW_ID_INJECTION=true`. * Local dry-run and build-only flows skip both injection and the check (no * on-disk side effects are expected, and build-only never talks to the * platform). @@ -207,8 +207,7 @@ export async function ensureConfigIdForDeploy(obj: { const { configPath, dryRun, buildOnly } = obj; if (buildOnly) return; - const allowCIInjection = - parseBoolean(process.env.TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION) === true; + const allowCIInjection = parseBoolean(process.env.TAILOR_CI_ALLOW_ID_INJECTION) === true; const strictCI = isCI && !allowCIInjection; if (dryRun) { diff --git a/packages/sdk/src/cli/commands/deploy/confirm.ts b/packages/sdk/src/cli/commands/deploy/confirm.ts index a805f970f..2159c59a3 100644 --- a/packages/sdk/src/cli/commands/deploy/confirm.ts +++ b/packages/sdk/src/cli/commands/deploy/confirm.ts @@ -118,7 +118,7 @@ async function confirmNameMismatch( } /** - * Confirm allowing tailor-sdk to manage previously unmanaged resources. + * Confirm allowing tailor to manage previously unmanaged resources. * @param resources - Unmanaged resources * @param appName - Target application name * @param yes - Whether to auto-confirm without prompting @@ -131,7 +131,7 @@ export async function confirmUnmanagedResources( ): Promise { if (resources.length === 0) return; - logger.warn("Existing resources not tracked by tailor-sdk were found:"); + logger.warn("Existing resources not tracked by tailor were found:"); logger.log(` ${styles.info("Resources")}:`); for (const r of resources) { @@ -139,7 +139,7 @@ export async function confirmUnmanagedResources( } logger.newline(); logger.log(" These resources may have been created by older SDK versions, Terraform, or CUE."); - logger.log(" To continue, confirm that tailor-sdk should manage them."); + logger.log(" To continue, confirm that tailor should manage them."); logger.log( " If they are managed by another tool (e.g., Terraform), cancel and manage them there instead.", ); @@ -152,7 +152,7 @@ export async function confirmUnmanagedResources( } const confirmed = await prompt.confirm({ - message: `Allow tailor-sdk to manage these resources for "${appName}"?`, + message: `Allow tailor to manage these resources for "${appName}"?`, default: false, }); if (!confirmed) { diff --git a/packages/sdk/src/cli/commands/deploy/deploy.ts b/packages/sdk/src/cli/commands/deploy/deploy.ts index c36b6bf45..ad74f3a77 100644 --- a/packages/sdk/src/cli/commands/deploy/deploy.ts +++ b/packages/sdk/src/cli/commands/deploy/deploy.ts @@ -4,7 +4,6 @@ import * as path from "pathe"; import { hashFile } from "#/cli/cache/hasher"; import { createCacheManager } from "#/cli/cache/manager"; import { loadApplication, type Application } from "#/cli/services/application"; -import { removeLegacyBundleFiles } from "#/cli/services/stale-cleanup"; import { assertUniqueTailorDBTypeNamesWithExternal } from "#/cli/services/tailordb/type-name-validation"; import { getOrNull, type OperatorClient } from "#/cli/shared/client"; import { loadConfig } from "#/cli/shared/config-loader"; @@ -950,7 +949,6 @@ export async function buildDeploymentTargets( buildTarget = buildDeploymentTarget, ...targetParams } = params; - await removeLegacyBundleFiles(path.resolve(getDistDir())); return Promise.all( configPaths.map((configPath, index) => @@ -1848,7 +1846,7 @@ async function deployInternal(options?: DeployOptions, cliContext?: DeployCLICon const configPaths = parseDeployConfigPaths(options?.configPath); const dryRun = options?.dryRun ?? false; const buildOnly = - options?.buildOnly ?? parseBoolean(process.env.TAILOR_PLATFORM_SDK_BUILD_ONLY) === true; + options?.buildOnly ?? parseBoolean(process.env.TAILOR_DEPLOY_BUILD_ONLY) === true; const preflightConfigs = buildOnly ? [] : await withSpan("config.preflight", () => @@ -1905,7 +1903,7 @@ async function deployInternal(options?: DeployOptions, cliContext?: DeployCLICon assertUniqueGlobalResourceNames(targets); // Note: the normal apply path intentionally skips writing bundle files to - // .tailor-sdk/. Bundles are kept in memory and uploaded directly to the + // .tailor/. Bundles are kept in memory and uploaded directly to the // function registry. To test a function locally, use `function test-run` // with a .ts source file instead of a pre-bundled .js file. diff --git a/packages/sdk/src/cli/commands/deploy/executor.test.ts b/packages/sdk/src/cli/commands/deploy/executor.test.ts index 99192ee44..06a08c066 100644 --- a/packages/sdk/src/cli/commands/deploy/executor.test.ts +++ b/packages/sdk/src/cli/commands/deploy/executor.test.ts @@ -16,7 +16,7 @@ vi.mock("node:fs", () => ({ // Mock dist-dir to avoid getDistDir issues vi.mock("#/cli/shared/dist-dir", () => ({ - getDistDir: vi.fn().mockReturnValue(".tailor-sdk"), + getDistDir: vi.fn().mockReturnValue(".tailor"), })); // Mock config values for tests @@ -145,6 +145,7 @@ describe("planExecutor", () => { resolverNames?: Record; idpNames?: ReadonlyArray; authName?: string; + externalAuthName?: string; }, ): Application { const tailorDBServices = Object.entries( @@ -165,7 +166,6 @@ describe("planExecutor", () => { return { name: appName, - config: {}, subgraphs: idpServices.map((idp) => ({ Type: "idp", Name: idp.name })), env: {}, executorService: createMockExecutorService(executors), @@ -173,6 +173,9 @@ describe("planExecutor", () => { resolverServices, idpServices, authService: options?.authName ? { config: { name: options.authName } } : undefined, + config: options?.externalAuthName + ? { auth: { name: options.externalAuthName, external: true } } + : {}, } as unknown as Application; } @@ -368,6 +371,29 @@ describe("planExecutor", () => { expect(result.changeSet.updates).toHaveLength(0); expect(result.changeSet.deletes).toHaveLength(0); }); + + test.each([ + ["false", false, "false"], + ["zero", 0, "0"], + ["empty string", "", '""'], + ])("preserves %s workflow args", async (_description, args, expectedExpression) => { + const executor = createMockExecutor("workflow-executor"); + executor.operation = { + kind: "workflow", + workflowName: "test-workflow", + args, + }; + + const result = await planExecutor( + buildPlanContext(createMockApplication([executor]), { client: createMockClient([]) }), + ); + const targetConfig = result.changeSet.creates[0]?.request.executor?.targetConfig?.config; + if (targetConfig?.case !== "workflow") { + throw new Error("Expected workflow target config"); + } + + expect(targetConfig.value.variables?.expr).toBe(expectedExpression); + }); }); describe("update scenarios", () => { @@ -560,6 +586,46 @@ describe("planExecutor", () => { expect(variablesExpr).toContain("result: args.succeeded?.result.resolver"); expect(variablesExpr).toContain("error: args.failed?.error"); }); + + test("string invoker uses external auth config name", async () => { + const client = createMockClient([]); + const executor: Executor = { + name: "test-executor", + description: "Executor test-executor", + disabled: false, + trigger: { + kind: "schedule", + timezone: "UTC", + cron: "0 * * * *", + }, + operation: { + kind: "function", + body: () => {}, + invoker: "batch-user", + }, + }; + const application = createMockApplication([executor], { + externalAuthName: "external-auth", + }); + + const result = await planExecutor({ + client, + workspaceId, + application, + forRemoval: false, + config: mockConfig, + }); + + const create = result.changeSet.creates[0]!; + const targetConfig = create.request.executor?.targetConfig?.config as { + case: "function"; + value: { invoker: { namespace: string; machineUserName: string } }; + }; + expect(targetConfig.value.invoker).toEqual({ + namespace: "external-auth", + machineUserName: "batch-user", + }); + }); }); describe("typed event config", () => { diff --git a/packages/sdk/src/cli/commands/deploy/executor.ts b/packages/sdk/src/cli/commands/deploy/executor.ts index c8f203bde..fc531538f 100644 --- a/packages/sdk/src/cli/commands/deploy/executor.ts +++ b/packages/sdk/src/cli/commands/deploy/executor.ts @@ -13,11 +13,11 @@ import { type ExecutorTriggerEventConfigSchema, ExecutorTriggerType, } from "@tailor-platform/tailor-proto/executor_resource_pb"; +import { getApplicationAuthNamespace } from "#/cli/shared/auth-namespace"; import { type OperatorClient } from "#/cli/shared/client"; import { buildExecutorArgsExpr } from "#/cli/shared/runtime-exprs"; import { stringifyFunction } from "#/parser/service/tailordb/index"; import { assertDefined } from "#/utils/assert"; -import { normalizeAuthInvoker } from "./auth-invoker"; import { createChangeSet, type ChangeSet } from "./change-set"; import { areNormalizedEqual, normalizeProtoConfig } from "./compare"; import { executorFunctionName } from "./function-registry"; @@ -26,6 +26,7 @@ import { type GroupedDisplayEntry, type RelatedFunctionRegistryChanges, } from "./grouped-display"; +import { normalizeInvoker } from "./invoker"; import { buildMetaRequest, hasMatchingSdkVersion, resourceTrn } from "./label"; import { fetchExistingResourcesWithLabels, @@ -478,7 +479,7 @@ function resolveIdpNamespace( } function resolveAuthNamespace(application: Readonly): string { - const authNamespace = application.authService?.config.name ?? application.config.auth?.name; + const authNamespace = getApplicationAuthNamespace(application); if (!authNamespace) { throw new Error("No Auth service configured"); } @@ -610,7 +611,7 @@ function protoExecutor( let targetType: ExecutorTargetType; let targetConfig: MessageInitShape; - const authNamespace = application.authService?.config.name; + const authNamespace = getApplicationAuthNamespace(application); const invokerContext = `Executor "${executor.name}"`; switch (target.kind) { @@ -666,7 +667,7 @@ function protoExecutor( expr: `(${stringifyFunction(target.variables)})(${argsExpr})`, } : undefined, - invoker: normalizeAuthInvoker(target.authInvoker, authNamespace, invokerContext), + invoker: normalizeInvoker(target.invoker, authNamespace, invokerContext), }, }, }; @@ -689,7 +690,7 @@ function protoExecutor( variables: { expr: argsExpr, }, - invoker: normalizeAuthInvoker(target.authInvoker, authNamespace, invokerContext), + invoker: normalizeInvoker(target.invoker, authNamespace, invokerContext), }, }, }; @@ -702,12 +703,13 @@ function protoExecutor( case: "workflow", value: { workflowName: target.workflowName, - variables: target.args - ? typeof target.args === "function" - ? { expr: `(${stringifyFunction(target.args)})(${argsExpr})` } - : { expr: JSON.stringify(target.args) } - : undefined, - invoker: normalizeAuthInvoker(target.authInvoker, authNamespace, invokerContext), + variables: + target.args !== undefined + ? typeof target.args === "function" + ? { expr: `(${stringifyFunction(target.args)})(${argsExpr})` } + : { expr: JSON.stringify(target.args) } + : undefined, + invoker: normalizeInvoker(target.invoker, authNamespace, invokerContext), }, }, }; diff --git a/packages/sdk/src/cli/commands/deploy/index.test.ts b/packages/sdk/src/cli/commands/deploy/index.test.ts index 3b97e74c4..7231e0d6c 100644 --- a/packages/sdk/src/cli/commands/deploy/index.test.ts +++ b/packages/sdk/src/cli/commands/deploy/index.test.ts @@ -19,10 +19,6 @@ describe("deployCommand", () => { vi.clearAllMocks(); }); - test("exposes 'apply' as an alias", () => { - expect(deployCommand.aliases).toContain("apply"); - }); - test("forwards workspace creation options", async () => { await runCommand(deployCommand, [ "--create-workspace", @@ -59,6 +55,7 @@ describe("deployCommand", () => { test("forwards global options needed to reproduce deploy", async () => { await runCommand(deployCommand, ["--env-file-if-exists", ".env.local", "--verbose", "--json"], { + // strip unknown keys globalArgs: z.object(commonArgs), }); diff --git a/packages/sdk/src/cli/commands/deploy/index.ts b/packages/sdk/src/cli/commands/deploy/index.ts index f0c58c19b..249c6a02a 100644 --- a/packages/sdk/src/cli/commands/deploy/index.ts +++ b/packages/sdk/src/cli/commands/deploy/index.ts @@ -7,48 +7,45 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; export const deployCommand = defineAppCommand({ name: "deploy", - aliases: ["apply"], description: "Deploy your application by applying the Tailor configuration.", - args: z - .object({ - ...workspaceArgs, - ...multiConfigArg, - ...confirmationArgs, - "create-workspace": arg(z.boolean().optional(), { - description: "Create a workspace when the account has none", - }), - "workspace-name": arg(z.string().optional(), { - description: "Name for a workspace created during deploy", - }), - "workspace-region": arg(z.string().optional(), { - description: "Region for a workspace created during deploy", - }), - "organization-id": arg(z.string().optional(), { - description: "Organization ID for a workspace created during deploy", - env: "TAILOR_PLATFORM_ORGANIZATION_ID", - }), - "folder-id": arg(z.string().optional(), { - description: "Folder ID for a workspace created during deploy", - env: "TAILOR_PLATFORM_FOLDER_ID", - }), - "dry-run": arg(z.boolean().optional(), { - alias: "d", - description: "Run the command without making any changes", - }), - "no-schema-check": arg(z.boolean().optional(), { - description: "Skip schema diff check against migration snapshots", - }), - "no-validate": arg(z.boolean().optional(), { - description: "Skip client-side validation against platform resource constraints", - }), - "no-cache": arg(z.boolean().optional(), { - description: "Disable bundle caching for this run", - }), - "clean-cache": arg(z.boolean().optional(), { - description: "Clean the bundle cache before building", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...multiConfigArg, + ...confirmationArgs, + "create-workspace": arg(z.boolean().optional(), { + description: "Create a workspace when the account has none", + }), + "workspace-name": arg(z.string().optional(), { + description: "Name for a workspace created during deploy", + }), + "workspace-region": arg(z.string().optional(), { + description: "Region for a workspace created during deploy", + }), + "organization-id": arg(z.string().optional(), { + description: "Organization ID for a workspace created during deploy", + env: "TAILOR_PLATFORM_ORGANIZATION_ID", + }), + "folder-id": arg(z.string().optional(), { + description: "Folder ID for a workspace created during deploy", + env: "TAILOR_PLATFORM_FOLDER_ID", + }), + "dry-run": arg(z.boolean().optional(), { + alias: "d", + description: "Run the command without making any changes", + }), + "no-schema-check": arg(z.boolean().optional(), { + description: "Skip schema diff check against migration snapshots", + }), + "no-validate": arg(z.boolean().optional(), { + description: "Skip client-side validation against platform resource constraints", + }), + "no-cache": arg(z.boolean().optional(), { + description: "Disable bundle caching for this run", + }), + "clean-cache": arg(z.boolean().optional(), { + description: "Clean the bundle cache before building", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); const { initTelemetry } = await import("#/cli/telemetry/index"); diff --git a/packages/sdk/src/cli/commands/deploy/invoker.test.ts b/packages/sdk/src/cli/commands/deploy/invoker.test.ts new file mode 100644 index 000000000..0726d3921 --- /dev/null +++ b/packages/sdk/src/cli/commands/deploy/invoker.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; +import { normalizeInvoker } from "./invoker"; + +describe("normalizeInvoker", () => { + test("returns undefined when invoker is undefined", () => { + expect(normalizeInvoker(undefined, "my-auth", "ctx")).toBeUndefined(); + }); + + test("expands a string to the object form using authNamespace", () => { + expect(normalizeInvoker("kiosk", "my-auth", "ctx")).toEqual({ + namespace: "my-auth", + machineUserName: "kiosk", + }); + }); + + test("passes through object form unchanged", () => { + expect( + normalizeInvoker({ namespace: "other-auth", machineUserName: "kiosk" }, "my-auth", "ctx"), + ).toEqual({ + namespace: "other-auth", + machineUserName: "kiosk", + }); + }); + + test("throws when string invoker is given without an authNamespace", () => { + expect(() => normalizeInvoker("kiosk", undefined, 'Resolver "foo"')).toThrow( + /Resolver "foo".*Configure an Auth service before using invoker/, + ); + }); +}); diff --git a/packages/sdk/src/cli/commands/deploy/invoker.ts b/packages/sdk/src/cli/commands/deploy/invoker.ts new file mode 100644 index 000000000..0109bc4ea --- /dev/null +++ b/packages/sdk/src/cli/commands/deploy/invoker.ts @@ -0,0 +1,31 @@ +import type { AuthInvoker } from "#/types/auth.generated"; + +/** + * Normalize an invoker value to the object form required by the proto payload. + * + * Accepts either: + * - `undefined` — returns undefined + * - a plain string (machine user name) — expands to `{ namespace, machineUserName }` using `authNamespace` + * - an internal object `{ namespace, machineUserName }` — returned as-is + * @param invoker - String machine user name or internal object form + * @param authNamespace - Auth service namespace (required when invoker is a string) + * @param context - Contextual label used in error messages (e.g. `resolver "foo"`) + * @returns Object form of the invoker, or undefined + */ +export function normalizeInvoker( + invoker: string | AuthInvoker | undefined, + authNamespace: string | undefined, + context: string, +): { namespace: string; machineUserName: string } | undefined { + if (invoker === undefined) return undefined; + if (typeof invoker === "string") { + if (!authNamespace) { + throw new Error( + `${context} uses a string invoker ("${invoker}"), but no Auth service is configured. ` + + `Configure an Auth service before using invoker.`, + ); + } + return { namespace: authNamespace, machineUserName: invoker }; + } + return invoker; +} diff --git a/packages/sdk/src/cli/commands/deploy/resolver.test.ts b/packages/sdk/src/cli/commands/deploy/resolver.test.ts index e41cb2071..84f9d0d44 100644 --- a/packages/sdk/src/cli/commands/deploy/resolver.test.ts +++ b/packages/sdk/src/cli/commands/deploy/resolver.test.ts @@ -345,13 +345,13 @@ describe("planPipeline (resolver service level)", () => { expect(result.changeSet.resolver.unchanged).toHaveLength(0); }); - test("resolver is updated when authInvoker differs", async () => { + test("resolver is updated when invoker differs", async () => { const pipeline = createPipeline({ name: "test-resolver", operation: 0, body: () => "hello", output: { type: "string", metadata: {} }, - authInvoker: { namespace: "my-auth", machineUserName: "batch-user" }, + invoker: { namespace: "my-auth", machineUserName: "batch-user" }, }); const desiredResolver = structuredClone(await getDesiredResolver(pipeline)); expect(desiredResolver).toBeDefined(); @@ -371,9 +371,9 @@ describe("planPipeline (resolver service level)", () => { }); }); -describe("processResolver authInvoker mapping", () => { +describe("processResolver invoker mapping", () => { function createResolverServiceWithAuthInvoker( - authInvoker?: Record | string, + invoker?: Record | string, ): ResolverService { return { namespace: "test-ns", @@ -384,7 +384,7 @@ describe("processResolver authInvoker mapping", () => { operation: "query", body: () => "hello", output: { type: "string", metadata: {}, fields: {} }, - ...(authInvoker !== undefined ? { authInvoker } : {}), + ...(invoker !== undefined ? { invoker } : {}), }, }, loadResolvers: vi.fn().mockResolvedValue(undefined), @@ -400,7 +400,7 @@ describe("processResolver authInvoker mapping", () => { return planPipeline(buildCtx({ client, application })); } - test("authInvoker is mapped to proto invoker field", async () => { + test("invoker is mapped to proto invoker field", async () => { const resolverService = createResolverServiceWithAuthInvoker({ namespace: "my-auth", machineUserName: "batch-user", @@ -418,7 +418,7 @@ describe("processResolver authInvoker mapping", () => { }); }); - test("string authInvoker is normalized using the configured auth service name", async () => { + test("string invoker is normalized using the configured auth service name", async () => { const resolverService = createResolverServiceWithAuthInvoker("batch-user"); const result = await planWithResolverService(resolverService, { @@ -435,7 +435,7 @@ describe("processResolver authInvoker mapping", () => { }); }); - test("string authInvoker without auth service configured throws", async () => { + test("string invoker without auth service configured throws", async () => { const resolverService = createResolverServiceWithAuthInvoker("batch-user"); await expect(planWithResolverService(resolverService)).rejects.toThrow( @@ -443,7 +443,7 @@ describe("processResolver authInvoker mapping", () => { ); }); - test("invoker is undefined when authInvoker is not set", async () => { + test("invoker is undefined when invoker is not set", async () => { const resolverService = createResolverServiceWithAuthInvoker(); const result = await planWithResolverService(resolverService); diff --git a/packages/sdk/src/cli/commands/deploy/resolver.ts b/packages/sdk/src/cli/commands/deploy/resolver.ts index 47a31b1df..ea89c128f 100644 --- a/packages/sdk/src/cli/commands/deploy/resolver.ts +++ b/packages/sdk/src/cli/commands/deploy/resolver.ts @@ -16,10 +16,10 @@ import { } from "@tailor-platform/tailor-proto/pipeline_resource_pb"; import * as inflection from "inflection"; import { type ResolverService } from "#/cli/services/resolver/service"; +import { getApplicationAuthNamespace } from "#/cli/shared/auth-namespace"; import { fetchAllTolerant, type OperatorClient } from "#/cli/shared/client"; import { buildResolverOperationHookExpr } from "#/cli/shared/runtime-exprs"; import { assertDefined } from "#/utils/assert"; -import { normalizeAuthInvoker } from "./auth-invoker"; import { createChangeSet, type ChangeSet } from "./change-set"; import { areNormalizedEqual, normalizeProtoConfig } from "./compare"; import { resolverFunctionName } from "./function-registry"; @@ -28,6 +28,7 @@ import { type GroupedDisplayEntry, type RelatedFunctionRegistryChanges, } from "./grouped-display"; +import { normalizeInvoker } from "./invoker"; import { buildMetaRequest, hasMatchingSdkVersion, resourceTrn } from "./label"; import { fetchExistingResourcesWithLabels, @@ -135,7 +136,7 @@ export async function planPipeline(context: PlanContext) { context.executorUsedResolvers ?? new Set(), deletedServices, application.env, - application.authService?.config.name, + getApplicationAuthNamespace(application), forceApplyAll, ); @@ -552,11 +553,7 @@ function processResolver( expr: buildResolverOperationHookExpr(env), }, postScript: `args.body`, - invoker: normalizeAuthInvoker( - resolver.authInvoker, - authNamespace, - `Resolver "${resolver.name}"`, - ), + invoker: normalizeInvoker(resolver.invoker, authNamespace, `Resolver "${resolver.name}"`), }, ]; diff --git a/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts b/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts index 8584106fd..df3de7a6d 100644 --- a/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts +++ b/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts @@ -10,7 +10,7 @@ import { import type { SecretsStateScope } from "./secrets-state"; vi.mock("#/cli/shared/dist-dir", () => ({ - getDistDir: () => "/tmp/tailor-sdk-test-secrets-state", + getDistDir: () => "/tmp/tailor-test-secrets-state", })); const scopeA = { diff --git a/packages/sdk/src/cli/commands/deploy/secrets-state.ts b/packages/sdk/src/cli/commands/deploy/secrets-state.ts index fe147dba4..97b455158 100644 --- a/packages/sdk/src/cli/commands/deploy/secrets-state.ts +++ b/packages/sdk/src/cli/commands/deploy/secrets-state.ts @@ -4,11 +4,13 @@ import * as path from "pathe"; import { z } from "zod"; import { getDistDir } from "#/cli/shared/dist-dir"; +// strip unknown keys const SecretsStateSchema = z.object({ vaults: z.record(z.string(), z.record(z.string(), z.string())), connections: z.record(z.string(), z.string()).optional(), }); +// strip unknown keys const PersistedSecretsStateSchema = z.object({ version: z.literal(1), workspaceId: z.string(), diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts index 8c630a408..9d721817d 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts @@ -437,20 +437,22 @@ describe("planTailorDB (service level)", () => { const displayNameField = profileField?.fields?.displayName; const contactEmailField = profileField?.fields?.contact!.fields?.email; - expect(displayNameField?.validate).toHaveLength(1); - expect(displayNameField?.validate?.[0]?.errorMessage).toBe("Display name is required"); - expect(displayNameField?.validate?.[0]?.script?.expr).toContain( - "!((_value ?? '').length > 0)", - ); - expect(displayNameField?.hooks?.create?.expr).toBe("(_value ?? '').trim()"); - expect(displayNameField?.hooks?.update?.expr).toBe("(_value ?? '').trim()"); - - expect(contactEmailField?.validate).toHaveLength(1); - expect(contactEmailField?.validate?.[0]?.errorMessage).toBe("Email must contain @"); - expect(contactEmailField?.validate?.[0]?.script?.expr).toContain( - "!((_value ?? '').includes('@'))", - ); - expect(contactEmailField?.hooks?.create?.expr).toBe("(_value ?? '').toLowerCase()"); + expect(displayNameField?.optionalOnCreate).toBe(true); + expect(displayNameField?.hooks).toBeUndefined(); + expect(displayNameField?.validate ?? []).toHaveLength(0); + expect(contactEmailField?.optionalOnCreate).toBe(true); + expect(contactEmailField?.hooks).toBeUndefined(); + expect(contactEmailField?.validate ?? []).toHaveLength(0); + + const hookExpr = createdType?.schema?.typeHook?.create?.expr ?? ""; + expect(hookExpr).toContain('"profile": Object.assign({}, _input["profile"], {'); + expect(hookExpr).toContain("(_value ?? '').trim()"); + expect(hookExpr).toContain("(_value ?? '').toLowerCase()"); + + const validateExpr = createdType?.schema?.typeValidate?.create?.expr ?? ""; + expect(validateExpr).toContain('__errs["profile.displayName"]'); + expect(validateExpr).toContain('__errs["profile.contact.email"]'); + expect(validateExpr).toContain('if (typeof __r === "string")'); }); }); diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts index 3e6399205..a90b3efc5 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts @@ -393,7 +393,7 @@ async function validateAndDetectMigrations( logger.error("Schema changes detected that are not in migration files:"); logger.log(formatMigrationCheckResults(migrationResults)); logger.newline(); - logger.info("Run 'tailor-sdk tailordb migration generate' to create migration files."); + logger.info("Run 'tailor tailordb migration generate' to create migration files."); logger.info("Or use '--no-schema-check' to skip this check."); throw new Error("Schema migration check failed"); } @@ -418,7 +418,7 @@ async function validateAndDetectMigrations( logger.info(" - Migration history is out of sync", { mode: "plain" }); logger.newline(); logger.info("To resolve:"); - logger.info(" - Run 'tailor-sdk tailordb migration status' to compare local vs remote.", { + logger.info(" - Run 'tailor tailordb migration status' to compare local vs remote.", { mode: "plain", }); logger.info(" - If remote is correct, update local types and run 'migration generate'.", { @@ -690,7 +690,7 @@ export async function applyTailorDB( } } catch (error) { handleOptionalToRequiredError(error, [ - "Run 'tailor-sdk tailordb migration generate' to create migration files.", + "Run 'tailor tailordb migration generate' to create migration files.", "Migration scripts allow you to handle existing data before applying the schema change.", ]); } @@ -848,14 +848,6 @@ const migrationSnapshotCache = { }, }; -/** - * Build the TailorDBType manifest for `typeName` from migration N's snapshot. - * @param migration - The pending migration whose snapshot to consult - * @param typeName - The type name to look up in the snapshot - * @param tailorDBInputs - Deploy inputs, used to resolve namespace gqlOperations - * @param executorUsedTypes - Types used by executors (drives publishRecordEvents default) - * @returns The manifest, or undefined if `typeName` is not in that snapshot. - */ function buildSnapshotTypeManifest( migration: PendingMigration, typeName: string, @@ -1541,7 +1533,7 @@ async function planServices( request: { workspaceId, namespaceName: tailordb.namespace, - // Set UTC to match tailorctl/terraform + // Keep generated TailorDB services aligned with Terraform defaults. defaultTimezone: "UTC", }, metaRequest, @@ -1736,6 +1728,8 @@ function normalizeComparableTailorDBType(type: unknown) { indexes?: Record; files?: Record; permission?: Record; + typeHook?: Record; + typeValidate?: Record; }; } | null; return normalizeTailorDBCompareValue( @@ -1749,6 +1743,10 @@ function normalizeComparableTailorDBType(type: unknown) { indexes: normalized?.schema?.indexes ?? {}, files: normalized?.schema?.files ?? {}, permission: normalized?.schema?.permission ?? {}, + // Hooks/validators are sent as type-level scripts; include them so a + // changed hook or validator is detected as an update. + typeHook: normalized?.schema?.typeHook ?? {}, + typeValidate: normalized?.schema?.typeValidate ?? {}, }, }, [], @@ -1763,13 +1761,17 @@ function normalizeTailorDBCompareValue( return value; } + if (typeof value === "boolean") { + if (path.at(-1) === "optionalOnCreate" && value === false) { + return undefined; + } + return value; + } + if (typeof value === "number" || typeof value === "bigint" || typeof value === "string") { if (matchesNumericStringPath(path) && isNumericLikeValue(value)) { return String(value); } - // Platform returns an empty string for `expr` (validate scripts) and field/type - // `description` when the SDK omitted them, while local manifests omit the key - // entirely. Treat the empty string as unset so it matches an omitted value. if ( (path.at(-1) === "expr" || path.at(-1) === "description") && value === tailordbCompareKnownDefaults.emptyExpression @@ -1780,9 +1782,16 @@ function normalizeTailorDBCompareValue( } if (Array.isArray(value)) { - return value + const items = value .map((item, index) => normalizeTailorDBCompareValue(item, [...path, index])) .filter((item) => item !== undefined); + // Field-level validators are no longer emitted by the SDK (they are aggregated + // into type-level type_validate). The platform still returns an empty `validate` + // array per field; treat it as unset so it matches the omitted local value. + if (items.length === 0 && path.at(-1) === "validate") { + return undefined; + } + return items; } if (!isPlainObject(value)) { @@ -2035,9 +2044,7 @@ function formatMigrationCheckResults(results: MigrationCheckResult[]): string { lines.push(`Namespace: ${result.namespace}`); if (!result.diff) { - lines.push( - " No migration snapshot found. Run 'tailor-sdk tailordb migration generate' first.", - ); + lines.push(" No migration snapshot found. Run 'tailor tailordb migration generate' first."); } else { lines.push(` ${formatDiffSummary(result.diff)}`); lines.push(""); diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/migration.ts b/packages/sdk/src/cli/commands/deploy/tailordb/migration.ts index ad45a9a9c..a127f5224 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/migration.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/migration.ts @@ -37,7 +37,7 @@ import type { TailorDBServiceConfig } from "#/types/tailordb.generated"; export interface MigrationExecutionOptions { client: OperatorClient; workspaceId: string; - authInvoker: AuthInvoker; + invoker: AuthInvoker; env: Record; } @@ -177,7 +177,7 @@ async function executeSingleMigration( options: MigrationExecutionOptions, migration: PendingMigration, ): Promise { - const { client, workspaceId, authInvoker, env } = options; + const { client, workspaceId, invoker, env } = options; const migrationName = `migration-${migration.namespace}-${formatMigrationNumber(migration.number)}.js`; @@ -195,7 +195,7 @@ async function executeSingleMigration( workspaceId, name: migrationName, code: bundleResult.bundledCode, - invoker: authInvoker, + invoker, }); return { @@ -274,8 +274,7 @@ export async function executeMigrations( ); } - // Create authInvoker for this namespace - const authInvoker = create(AuthInvokerSchema, { + const invoker = create(AuthInvokerSchema, { namespace: context.authNamespace, machineUserName, }); @@ -283,7 +282,7 @@ export async function executeMigrations( const options: MigrationExecutionOptions = { client: context.client, workspaceId: context.workspaceId, - authInvoker, + invoker, env: context.env, }; diff --git a/packages/sdk/src/cli/commands/deploy/workflow.test.ts b/packages/sdk/src/cli/commands/deploy/workflow.test.ts index e43aba9bf..a1bc48871 100644 --- a/packages/sdk/src/cli/commands/deploy/workflow.test.ts +++ b/packages/sdk/src/cli/commands/deploy/workflow.test.ts @@ -42,7 +42,7 @@ describe("planWorkflow", () => { function createMockJob(name: string): WorkflowJob { return { name, - trigger: () => {}, + start: () => {}, body: () => {}, }; } @@ -761,7 +761,7 @@ describe("formatWorkflowChangeEntries", () => { workspaceId: "ws", workflow: { name: "order-processing", - mainJob: { name: "process-order", body: () => {}, trigger: () => {} }, + mainJob: { name: "process-order", body: () => {}, start: () => {} }, }, usedJobNames: ["process-order"], metaRequest: { trn: "t", labels: {} }, diff --git a/packages/sdk/src/cli/commands/deploy/workspace-context.test.ts b/packages/sdk/src/cli/commands/deploy/workspace-context.test.ts index eff220b93..7816aa149 100644 --- a/packages/sdk/src/cli/commands/deploy/workspace-context.test.ts +++ b/packages/sdk/src/cli/commands/deploy/workspace-context.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { loadWorkspaceContext, saveWorkspaceContext } from "./workspace-context"; const temporaryDirectories: string[] = []; @@ -12,7 +12,15 @@ async function temporaryDirectory(): Promise { return directory; } +// getDistDir() memoizes its result across calls in the same worker; pin it +// explicitly so this file's assumed ".tailor" dirname holds regardless of +// what other shared-worker test files set TAILOR_BUILD_OUTPUT_DIR to. +beforeEach(() => { + process.env.TAILOR_BUILD_OUTPUT_DIR = ".tailor"; +}); + afterEach(async () => { + delete process.env.TAILOR_BUILD_OUTPUT_DIR; const { rm } = await import("node:fs/promises"); await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), @@ -35,7 +43,7 @@ describe("workspace context", () => { context, ); await expect( - readFile(join(projectDirectory, ".tailor-sdk", "tailor.config.ts.context.json"), "utf8"), + readFile(join(projectDirectory, ".tailor", "tailor.config.ts.context.json"), "utf8"), ).resolves.toBe(`${JSON.stringify(context, null, 2)}\n`); }); @@ -62,7 +70,7 @@ describe("workspace context", () => { await expect( loadWorkspaceContext("https://api.tailor.tech", secondConfigPath), ).resolves.toEqual(secondContext); - await expect(readdir(join(projectDirectory, ".tailor-sdk"))).resolves.toEqual([ + await expect(readdir(join(projectDirectory, ".tailor"))).resolves.toEqual([ "first.config.ts.context.json", "second.config.ts.context.json", ]); @@ -90,7 +98,7 @@ describe("workspace context", () => { test("does not rewrite an unchanged context", async () => { const projectDirectory = await temporaryDirectory(); const configPath = join(projectDirectory, "tailor.config.ts"); - const targetPath = join(projectDirectory, ".tailor-sdk", "tailor.config.ts.context.json"); + const targetPath = join(projectDirectory, ".tailor", "tailor.config.ts.context.json"); const context = { version: 1 as const, platformUrl: "https://api.tailor.tech", @@ -123,7 +131,7 @@ describe("workspace context", () => { test("removes the temporary file when the atomic rename fails", async () => { const projectDirectory = await temporaryDirectory(); const configPath = join(projectDirectory, "tailor.config.ts"); - const stateDirectory = join(projectDirectory, ".tailor-sdk"); + const stateDirectory = join(projectDirectory, ".tailor"); const targetPath = join(stateDirectory, "tailor.config.ts.context.json"); await mkdir(targetPath, { recursive: true }); @@ -156,7 +164,7 @@ describe("workspace context", () => { const projectDirectory = await temporaryDirectory(); const configPath = join(projectDirectory, "tailor.config.ts"); if (contents !== undefined) { - const stateDirectory = join(projectDirectory, ".tailor-sdk"); + const stateDirectory = join(projectDirectory, ".tailor"); await mkdir(stateDirectory, { recursive: true }); await writeFile(join(stateDirectory, "tailor.config.ts.context.json"), contents); } diff --git a/packages/sdk/src/cli/commands/deploy/workspace-context.ts b/packages/sdk/src/cli/commands/deploy/workspace-context.ts index 24302f718..23994b51b 100644 --- a/packages/sdk/src/cli/commands/deploy/workspace-context.ts +++ b/packages/sdk/src/cli/commands/deploy/workspace-context.ts @@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto"; import { readFile, mkdir, rename, rm, writeFile } from "node:fs/promises"; import { basename, dirname, join, resolve } from "pathe"; import { z } from "zod"; +import { getDistDir } from "#/cli/shared/dist-dir"; +// strip unknown keys const workspaceContextSchema = z.object({ version: z.literal(1), platformUrl: z.url(), @@ -17,7 +19,7 @@ function defaultConfigPath(): string { } function contextPath(configPath: string): string { - return join(dirname(configPath), ".tailor-sdk", `${basename(configPath)}.context.json`); + return join(dirname(configPath), getDistDir(), `${basename(configPath)}.context.json`); } /** @@ -74,7 +76,7 @@ export async function saveWorkspaceContext( ...context, ...(applicationId === undefined ? {} : { applicationId }), }); - const stateDirectory = join(dirname(configPath), ".tailor-sdk"); + const stateDirectory = join(dirname(configPath), getDistDir()); const targetPath = contextPath(configPath); const serialized = `${JSON.stringify(validated, null, 2)}\n`; try { diff --git a/packages/sdk/src/cli/commands/executor/get.ts b/packages/sdk/src/cli/commands/executor/get.ts index 4cfddb9ac..f41894210 100644 --- a/packages/sdk/src/cli/commands/executor/get.ts +++ b/packages/sdk/src/cli/commands/executor/get.ts @@ -93,12 +93,10 @@ export async function getExecutor( export const getCommand = defineAppCommand({ name: "get", description: "Get executor details", - args: z - .object({ - ...workspaceArgs, - ...nameArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...nameArgs, + }), run: async (args) => { const executor = await getExecutor({ name: args.name, diff --git a/packages/sdk/src/cli/commands/executor/jobs.ts b/packages/sdk/src/cli/commands/executor/jobs.ts index ca75add48..bc33af85c 100644 --- a/packages/sdk/src/cli/commands/executor/jobs.ts +++ b/packages/sdk/src/cli/commands/executor/jobs.ts @@ -739,48 +739,46 @@ export const jobsCommand = defineAppCommand({ desc: "Wait for job with logs", }, ], - args: z - .object({ - ...workspaceArgs, - "executor-name": arg(z.string(), { - positional: true, - description: "Executor name", - }), - "job-id": arg(z.string().optional(), { - positional: true, - description: "Job ID (if provided, shows job details)", - }), - status: arg(z.string().optional(), { - alias: "s", - description: - "Filter by status (PENDING, RUNNING, SUCCESS, FAILED, CANCELED) (list mode only)", - }), - attempts: arg(z.boolean().default(false), { - description: "Show job attempts (only with job ID) (detail mode only)", - }), - wait: arg(z.boolean().default(false), { - alias: "W", - description: - "Wait for job completion and downstream execution (workflow/function) if applicable (detail mode only)", - }), - interval: arg(durationArg.default("3s"), { - alias: "i", - description: "Polling interval when using --wait (e.g., '3s', '500ms', '1m')", - }), - timeout: arg(durationArg.default("5m"), { - alias: "t", - description: "Maximum time to wait when using --wait (e.g., '30s', '5m')", - }), - ...pagedLogArgs, - limit: arg(nonNegativeIntArg.default(50), { - description: "Maximum number of jobs to list (0: unlimited, default: 50) (list mode only)", - }), - logs: arg(z.boolean().default(false), { - alias: "l", - description: "Display function execution logs after completion (requires --wait)", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + "executor-name": arg(z.string(), { + positional: true, + description: "Executor name", + }), + "job-id": arg(z.string().optional(), { + positional: true, + description: "Job ID (if provided, shows job details)", + }), + status: arg(z.string().optional(), { + alias: "s", + description: + "Filter by status (PENDING, RUNNING, SUCCESS, FAILED, CANCELED) (list mode only)", + }), + attempts: arg(z.boolean().default(false), { + description: "Show job attempts (only with job ID) (detail mode only)", + }), + wait: arg(z.boolean().default(false), { + alias: "W", + description: + "Wait for job completion and downstream execution (workflow/function) if applicable (detail mode only)", + }), + interval: arg(durationArg.default("3s"), { + alias: "i", + description: "Polling interval when using --wait (e.g., '3s', '500ms', '1m')", + }), + timeout: arg(durationArg.default("5m"), { + alias: "t", + description: "Maximum time to wait when using --wait (e.g., '30s', '5m')", + }), + ...pagedLogArgs, + limit: arg(nonNegativeIntArg.default(50), { + description: "Maximum number of jobs to list (0: unlimited, default: 50) (list mode only)", + }), + logs: arg(z.boolean().default(false), { + alias: "l", + description: "Display function execution logs after completion (requires --wait)", + }), + }), run: async (args) => { const jsonOutput = logger.jsonMode || args.json; if (args.jobId) { diff --git a/packages/sdk/src/cli/commands/executor/list.ts b/packages/sdk/src/cli/commands/executor/list.ts index 9cf743074..b6d96f985 100644 --- a/packages/sdk/src/cli/commands/executor/list.ts +++ b/packages/sdk/src/cli/commands/executor/list.ts @@ -48,12 +48,10 @@ export async function listExecutors(options?: ListExecutorsOptions): Promise { const jsonOutput = logger.jsonMode; const executors = await listExecutors({ @@ -81,7 +79,7 @@ export const listCommand = defineAppCommand({ if (!jsonOutput) { const hasWebhook = executors.some((e) => e.triggerType === "webhook"); if (hasWebhook) { - logger.info("To see webhook URLs, run: tailor-sdk executor webhook list"); + logger.info("To see webhook URLs, run: tailor executor webhook list"); } } }, diff --git a/packages/sdk/src/cli/commands/executor/trigger.ts b/packages/sdk/src/cli/commands/executor/trigger.ts index ec36913f3..deecd3aa2 100644 --- a/packages/sdk/src/cli/commands/executor/trigger.ts +++ b/packages/sdk/src/cli/commands/executor/trigger.ts @@ -184,41 +184,39 @@ The \`--logs\` option displays logs from the downstream execution when available { cmd: "my-executor -W", desc: "Trigger and wait for completion" }, { cmd: "my-executor -W -l", desc: "Trigger, wait, and show logs" }, ], - args: z - .object({ - ...workspaceArgs, - "executor-name": arg(z.string(), { - positional: true, - description: "Executor name", - }), - data: arg(jsonDataArg.optional(), { - alias: "d", - description: "Request body (JSON string)", - }), - header: arg(headerArg.array().optional(), { - alias: "H", - overrideBuiltinAlias: true, - description: "Request header (format: 'Key: Value', can be specified multiple times)", - }), - wait: arg(z.boolean().default(false), { - alias: "W", - description: - "Wait for job completion and downstream execution (workflow/function) if applicable", - }), - interval: arg(durationArg.default("3s"), { - alias: "i", - description: "Polling interval when using --wait (e.g., '3s', '500ms', '1m')", - }), - timeout: arg(durationArg.default("5m"), { - alias: "t", - description: "Maximum time to wait when using --wait (e.g., '30s', '5m')", - }), - logs: arg(z.boolean().default(false), { - alias: "l", - description: "Display function execution logs after completion (requires --wait)", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + "executor-name": arg(z.string(), { + positional: true, + description: "Executor name", + }), + data: arg(jsonDataArg.optional(), { + alias: "d", + description: "Request body (JSON string)", + }), + header: arg(headerArg.array().optional(), { + alias: "H", + overrideBuiltinAlias: true, + description: "Request header (format: 'Key: Value', can be specified multiple times)", + }), + wait: arg(z.boolean().default(false), { + alias: "W", + description: + "Wait for job completion and downstream execution (workflow/function) if applicable", + }), + interval: arg(durationArg.default("3s"), { + alias: "i", + description: "Polling interval when using --wait (e.g., '3s', '500ms', '1m')", + }), + timeout: arg(durationArg.default("5m"), { + alias: "t", + description: "Maximum time to wait when using --wait (e.g., '30s', '5m')", + }), + logs: arg(z.boolean().default(false), { + alias: "l", + description: "Display function execution logs after completion (requires --wait)", + }), + }), run: async (args) => { const jsonOutput = logger.jsonMode || args.json; await assertWritable({ profile: args.profile }); diff --git a/packages/sdk/src/cli/commands/executor/webhook.ts b/packages/sdk/src/cli/commands/executor/webhook.ts index e6fa4d6a2..0fcf0e627 100644 --- a/packages/sdk/src/cli/commands/executor/webhook.ts +++ b/packages/sdk/src/cli/commands/executor/webhook.ts @@ -60,12 +60,10 @@ export async function listWebhookExecutors( const listWebhookCommand = defineAppCommand({ name: "list", description: "List executors with incoming webhook triggers", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const jsonOutput = logger.jsonMode; const executors = await listWebhookExecutors({ @@ -90,9 +88,7 @@ const listWebhookCommand = defineAppCommand({ }); if (!jsonOutput) { - logger.info( - 'To test a webhook, run: tailor-sdk executor trigger -d \'{"key":"value"}\'', - ); + logger.info('To test a webhook, run: tailor executor trigger -d \'{"key":"value"}\''); } }, }); diff --git a/packages/sdk/src/cli/commands/function/bundle.test.ts b/packages/sdk/src/cli/commands/function/bundle.test.ts index c636695f5..ab21a3547 100644 --- a/packages/sdk/src/cli/commands/function/bundle.test.ts +++ b/packages/sdk/src/cli/commands/function/bundle.test.ts @@ -22,11 +22,11 @@ describe("bundleForTestRun", () => { beforeEach(() => { testDir = path.join(TEST_BASE, `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); fs.mkdirSync(testDir, { recursive: true }); - process.env.TAILOR_SDK_OUTPUT_DIR = testDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = testDir; }); afterAll(() => { - delete process.env.TAILOR_SDK_OUTPUT_DIR; + delete process.env.TAILOR_BUILD_OUTPUT_DIR; try { fs.rmSync(TEST_BASE, { recursive: true, force: true }); } catch { @@ -234,7 +234,7 @@ export default { detected, ); - expect(result.bundledCode).toContain("USER_TYPE_MACHINE_USER"); + expect(result.bundledCode).toContain("machine_user"); expect(result.bundledCode).toContain("ADMIN"); expect(result.bundledCode).toContain(defaultMachineUser.id); expect(result.bundledCode).toContain(defaultWorkspaceId); diff --git a/packages/sdk/src/cli/commands/function/bundle.ts b/packages/sdk/src/cli/commands/function/bundle.ts index e0ad41d59..929fce9c5 100644 --- a/packages/sdk/src/cli/commands/function/bundle.ts +++ b/packages/sdk/src/cli/commands/function/bundle.ts @@ -24,7 +24,7 @@ import ml from "#/utils/multiline"; import type { LogLevelInput } from "#/configure/config/types"; import type { DetectedFunction } from "./detect"; -/** Machine user info resolved from config and API for bundle-time user context. */ +/** Machine user info resolved from config and API for bundle-time principal context. */ export interface ResolvedMachineUser { /** Machine user name */ name: string; @@ -47,7 +47,7 @@ interface BundleForTestRunOptions { inlineSourcemap?: boolean; /** Log level config value from defineConfig */ logLevel?: LogLevelInput; - /** Machine user info for injecting user context into the bundle */ + /** Machine user info for injecting principal context into the bundle */ machineUser: ResolvedMachineUser; /** Workspace ID for user context */ workspaceId: string; @@ -152,23 +152,23 @@ function generateEntry( case "resolver": { // Mirrors the production resolver bundler (services/resolver/bundler.ts). - // In production, the operationHook injects user/env into context. + // In production, the operationHook injects caller/env into context. // For test-run, we embed machine user info since there's no operationHook. - const userExpr = buildMachineUserExpr(machineUser, workspaceId); + const principalExpr = buildMachinePrincipalExpr(machineUser, workspaceId); return ml /* js */ ` import _internalResolver from "${absoluteSourcePath}"; import { t } from "@tailor-platform/sdk"; const _env = ${JSON.stringify(env)}; - const _user = ${userExpr}; + const _caller = ${principalExpr}; const $tailor_resolver_body = async (context) => { - const _invoker = ${INVOKER_EXPR}; + const _invoker = ${INVOKER_EXPR} ?? _caller; if (_internalResolver.input) { const result = t.object(_internalResolver.input).parse({ value: context, data: context, - user: _user, + invoker: _invoker, }); if (result.issues) { @@ -179,7 +179,7 @@ function generateEntry( } } - const enrichedContext = { input: context, env: _env, user: _user, invoker: _invoker }; + const enrichedContext = { input: context, env: _env, caller: _caller, invoker: _invoker }; return _internalResolver.body(enrichedContext); }; @@ -191,15 +191,15 @@ function generateEntry( // Mirrors the production executor bundler (services/executor/bundler.ts). // In production, buildExecutorArgsExpr injects actor/env into args. // For test-run, we embed machine user as actor. - const actorExpr = buildMachineActorExpr(machineUser, workspaceId); + const principalExpr = buildMachinePrincipalExpr(machineUser, workspaceId); return ml /* js */ ` import _internalExecutor from "${absoluteSourcePath}"; const _env = ${JSON.stringify(env)}; - const _actor = ${actorExpr}; + const _actor = ${principalExpr}; const __executor_function = async (args) => { - const _invoker = ${INVOKER_EXPR}; + const _invoker = ${INVOKER_EXPR} ?? _actor; return _internalExecutor.operation.body({ ...args, env: _env, actor: _actor, invoker: _invoker }); }; @@ -212,13 +212,15 @@ function generateEntry( // Note: user context is not available in TestExecScript for workflow jobs. // The production workflow bundler's user mapping is being fixed in fix/workflow-user. const exportName = assertDefined(detected.exportName, "workflow job export name missing"); + const principalExpr = buildMachinePrincipalExpr(machineUser, workspaceId); return ml /* js */ ` import { ${exportName} } from "${absoluteSourcePath}"; const env = ${JSON.stringify(env)}; + const fallbackInvoker = ${principalExpr}; export async function main(input) { - const invoker = ${INVOKER_EXPR}; + const invoker = ${INVOKER_EXPR} ?? fallbackInvoker; return await ${exportName}.body(input, { env, invoker }); } `; @@ -227,33 +229,17 @@ function generateEntry( } /** - * Build a JSON expression for a machine user TailorUser object. + * Build a JSON expression for a machine user TailorPrincipal object. * @param machineUser - Resolved machine user info * @param workspaceId - Workspace ID * @returns JSON string for the user expression */ -function buildMachineUserExpr(machineUser: ResolvedMachineUser, workspaceId: string): string { +function buildMachinePrincipalExpr(machineUser: ResolvedMachineUser, workspaceId: string): string { return JSON.stringify({ id: machineUser.id, type: "machine_user", workspaceId, - attributes: machineUser.attributes, + attributes: machineUser.attributes ?? {}, attributeList: machineUser.attributeList, }); } - -/** - * Build a JSON expression for a machine user TailorActor object. - * @param machineUser - Resolved machine user info - * @param workspaceId - Workspace ID - * @returns JSON string for the actor expression - */ -function buildMachineActorExpr(machineUser: ResolvedMachineUser, workspaceId: string): string { - return JSON.stringify({ - workspaceId, - userId: machineUser.id, - attributes: machineUser.attributes, - attributeList: machineUser.attributeList, - userType: "USER_TYPE_MACHINE_USER", - }); -} diff --git a/packages/sdk/src/cli/commands/function/detect.test.ts b/packages/sdk/src/cli/commands/function/detect.test.ts index 9d1e924c1..847afc6c1 100644 --- a/packages/sdk/src/cli/commands/function/detect.test.ts +++ b/packages/sdk/src/cli/commands/function/detect.test.ts @@ -3,16 +3,8 @@ import { pathToFileURL } from "node:url"; import * as path from "pathe"; import { afterAll, beforeEach, describe, expect, test } from "vitest"; import { detectFunctionType } from "./detect"; -import type { TailorUser } from "#/runtime/types"; const TEST_BASE = path.join(__dirname, "__test_detect__"); -const user: TailorUser = { - id: "00000000-0000-0000-0000-000000000000", - type: "machine_user", - workspaceId: "test-workspace", - attributes: null, - attributeList: [], -}; describe("detectFunctionType", () => { let testDir: string; @@ -55,6 +47,32 @@ export default { expect(result.name).toBe("my-resolver"); }); + test("rejects a branded resolver with unknown helper keys", async () => { + const filePath = path.join(testDir, "resolver-with-helper.mjs"); + fs.writeFileSync( + filePath, + ` +const resolver = { + operation: "query", + name: "my-resolver", + body: (ctx) => ctx.input, + output: { type: "string", metadata: {}, fields: {} }, + trigger: () => {}, +}; + +Object.defineProperty(resolver, Symbol.for("tailor-platform/sdk"), { + value: "resolver", +}); + +export default resolver; +`, + ); + + await expect(detectFunctionType({ filePath })).rejects.toThrow( + "Could not detect function type", + ); + }); + test("builds a working local input parser from real t.* fields", async () => { const configureTypesUrl = pathToFileURL( path.join(__dirname, "../../../configure/types/index.ts"), @@ -76,13 +94,17 @@ export default { const result = await detectFunctionType({ filePath }); expect(result.hasInput).toBe(true); - const valid = result.inputSchema?.parse({ value: { name: "a", age: 1 }, data: {}, user }); + const valid = result.inputSchema?.parse({ + value: { name: "a", age: 1 }, + data: {}, + invoker: null, + }); expect(valid?.issues).toBeUndefined(); const invalid = result.inputSchema?.parse({ value: { age: "not-a-number" }, data: {}, - user, + invoker: null, }); expect(invalid?.issues).toEqual([ { message: "Required field is missing", path: ["name"] }, @@ -112,6 +134,33 @@ export default { expect(result.name).toBe("my-executor"); }); + test("detects a function executor with trigger helper args", async () => { + const filePath = path.join(testDir, "executor-with-helper.mjs"); + fs.writeFileSync( + filePath, + ` +const executor = { + name: "my-executor", + trigger: { kind: "schedule", cron: "0 12 * * *", __args: [{ cron: "0 12 * * *" }] }, + operation: { + kind: "function", + body: (args) => {}, + }, +}; + +Object.defineProperty(executor, Symbol.for("tailor-platform/sdk"), { + value: "executor", +}); + +export default executor; +`, + ); + + const result = await detectFunctionType({ filePath }); + expect(result.type).toBe("executor"); + expect(result.name).toBe("my-executor"); + }); + test("does not detect a non-function executor", async () => { const filePath = writeFile( "gql-executor.mjs", @@ -136,13 +185,13 @@ export default { const multiJobSource = ` export const job_a = { name: "job-a", - trigger: () => {}, + start: () => {}, body: (input) => input, }; export const job_b = { name: "job-b", - trigger: () => {}, + start: () => {}, body: (input) => input, }; `; @@ -153,13 +202,13 @@ export const job_b = { ` export const my_job = { name: "my-job", - trigger: () => {}, + start: () => {}, body: (input) => input, }; export default { name: "my-workflow", - mainJob: { name: "my-job", trigger: () => {}, body: () => {} }, + mainJob: { name: "my-job", start: () => {}, body: () => {} }, }; `, ); @@ -193,7 +242,7 @@ export default { ` export const my_job = { name: "my-job", - trigger: () => {}, + start: () => {}, body: (input) => input, }; `, diff --git a/packages/sdk/src/cli/commands/function/detect.ts b/packages/sdk/src/cli/commands/function/detect.ts index ba90bac74..4f929061a 100644 --- a/packages/sdk/src/cli/commands/function/detect.ts +++ b/packages/sdk/src/cli/commands/function/detect.ts @@ -7,19 +7,20 @@ import { pathToFileURL } from "node:url"; import * as path from "pathe"; +import { stripExecutorTriggerArgs } from "#/cli/services/executor/loader"; import { ExecutorSchema } from "#/parser/service/executor/index"; import { ResolverSchema } from "#/parser/service/resolver/index"; import { WorkflowJobSchema } from "#/parser/service/workflow/index"; import { type FieldRuntime, parseInputFields } from "#/runtime/field-parse"; import { assertDefined } from "#/utils/assert"; -import type { TailorUser } from "#/runtime/types"; +import type { TailorPrincipal } from "#/runtime/types"; export type FunctionType = "resolver" | "executor" | "workflow-job" | "plain"; interface InputParseArgs { value: unknown; data: unknown; - user: TailorUser; + invoker: TailorPrincipal | null; } /** Minimal schema interface for local format detection (subset of TailorField) */ @@ -87,7 +88,7 @@ export async function detectFunctionType( } // 2. Check executor (only function/jobFunction kinds) - const executorResult = ExecutorSchema.safeParse(module.default); + const executorResult = ExecutorSchema.safeParse(stripExecutorTriggerArgs(module.default)); if (executorResult.success) { const { operation } = executorResult.data; if (operation.kind === "function" || operation.kind === "jobFunction") { diff --git a/packages/sdk/src/cli/commands/function/get.ts b/packages/sdk/src/cli/commands/function/get.ts index ae9675313..8a24114e9 100644 --- a/packages/sdk/src/cli/commands/function/get.ts +++ b/packages/sdk/src/cli/commands/function/get.ts @@ -10,6 +10,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { functionRegistryInfo, type FunctionRegistryInfo } from "./transform"; +// strip unknown keys const getFunctionRegistryOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -71,15 +72,13 @@ export async function getFunctionRegistry( export const getCommand = defineAppCommand({ name: "get", description: "Get a function registry by name", - args: z - .object({ - ...workspaceArgs, - name: arg(z.string(), { - description: "Function name", - alias: "n", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + name: arg(z.string(), { + description: "Function name", + alias: "n", + }), + }), run: async (args) => { const fn = await getFunctionRegistry({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/function/list.ts b/packages/sdk/src/cli/commands/function/list.ts index a6a2896b6..789b50ba9 100644 --- a/packages/sdk/src/cli/commands/function/list.ts +++ b/packages/sdk/src/cli/commands/function/list.ts @@ -9,6 +9,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { functionRegistryInfo, type FunctionRegistryInfo } from "./transform"; +// strip unknown keys const listFunctionRegistriesOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -77,12 +78,10 @@ export async function listFunctionRegistries( export const listCommand = defineAppCommand({ name: "list", description: "List function registries in a workspace", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const jsonOutput = logger.jsonMode; const registries = await listFunctionRegistries({ diff --git a/packages/sdk/src/cli/commands/function/logs.test.ts b/packages/sdk/src/cli/commands/function/logs.test.ts index 74b63cb38..02cdd3182 100644 --- a/packages/sdk/src/cli/commands/function/logs.test.ts +++ b/packages/sdk/src/cli/commands/function/logs.test.ts @@ -190,7 +190,6 @@ describe("downloadScriptForMapping", () => { scriptName: "test-run--throwError.js", executionType: FunctionExecution_Type.STANDARD, executionContentHash: "abc123", - executionStartedAt: new Date("2024-01-01T00:00:00Z"), }); expect(result).toBeNull(); @@ -207,7 +206,6 @@ describe("downloadScriptForMapping", () => { scriptName: "my-resolver.throwError.body.js", executionType: FunctionExecution_Type.STANDARD, executionContentHash: "abc123", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), }); expect(result).toBe("pinned-code"); @@ -219,9 +217,8 @@ describe("downloadScriptForMapping", () => { }); test("returns code even when registry was redeployed after the execution", async () => { - // Registry metadata reports updatedAt newer than the execution. - // The legacy timestamp-based check would skip this, but pinning - // by contentHash asks the server for the exact bundle that ran. + // Pinning by contentHash asks the server for the exact bundle that + // ran, so a newer registry updatedAt does not affect the result. const client = makeDownloadClient([new TextEncoder().encode("pinned-code")], { updatedAt: new Date("2024-03-01T00:00:00Z"), }); @@ -232,7 +229,6 @@ describe("downloadScriptForMapping", () => { scriptName: "my-resolver.throwError.body.js", executionType: FunctionExecution_Type.STANDARD, executionContentHash: "abc123", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), }); expect(result).toBe("pinned-code"); @@ -247,7 +243,6 @@ describe("downloadScriptForMapping", () => { scriptName: "billing.retry.v2", executionType: FunctionExecution_Type.JOB, executionContentHash: "deadbeef", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), }); expect(result).toBe("job-code"); @@ -267,80 +262,15 @@ describe("downloadScriptForMapping", () => { scriptName: "my-resolver.throwError.body.js", executionType: FunctionExecution_Type.STANDARD, executionContentHash: "abc123", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), }); expect(result).toBeNull(); }); }); - describe("with updatedAt fallback (executionContentHash empty)", () => { - test("does not pin contentHash on the RPC", async () => { - const client = makeDownloadClient([new TextEncoder().encode("code")], { - updatedAt: new Date("2024-01-01T00:00:00Z"), - }); - - await downloadScriptForMapping({ - client, - workspaceId: "ws-1", - scriptName: "my-resolver.throwError.body.js", - executionType: FunctionExecution_Type.STANDARD, - executionContentHash: "", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), - }); - - expect(client.downloadFunctionRegistryScript).toHaveBeenCalledWith({ - workspaceId: "ws-1", - name: "resolver--my-resolver--throwError", - contentHash: undefined, - }); - }); - - test.each([ - { - label: "registry updatedAt is not newer than executionStartedAt", - updatedAt: new Date("2024-01-01T00:00:00Z"), - executionStartedAt: new Date("2024-02-01T00:00:00Z"), - expected: "code", - }, - { - label: "registry updatedAt is strictly newer than executionStartedAt", - updatedAt: new Date("2024-03-01T00:00:00Z"), - executionStartedAt: new Date("2024-02-01T00:00:00Z"), - expected: null, - }, - { - label: "executionStartedAt is null (no staleness check possible)", - updatedAt: new Date("2024-03-01T00:00:00Z"), - executionStartedAt: null, - expected: "code", - }, - { - label: "registry metadata omits updatedAt", - updatedAt: undefined, - executionStartedAt: new Date("2024-02-01T00:00:00Z"), - expected: "code", - }, - ])("when $label", async ({ updatedAt, executionStartedAt, expected }) => { - const client = makeDownloadClient( - [new TextEncoder().encode("code")], - updatedAt ? { updatedAt } : undefined, - ); - - const result = await downloadScriptForMapping({ - client, - workspaceId: "ws-1", - scriptName: "my-resolver.throwError.body.js", - executionType: FunctionExecution_Type.STANDARD, - executionContentHash: "", - executionStartedAt, - }); - - expect(result).toBe(expected); - }); - - test("returns null when download yields no chunks", async () => { - const client = makeDownloadClient([], { updatedAt: new Date("2024-01-01T00:00:00Z") }); + describe("without executionContentHash", () => { + test("skips mapping without downloading the current registry script", async () => { + const client = makeDownloadClient([new TextEncoder().encode("code")]); const result = await downloadScriptForMapping({ client, @@ -348,10 +278,10 @@ describe("downloadScriptForMapping", () => { scriptName: "my-resolver.throwError.body.js", executionType: FunctionExecution_Type.STANDARD, executionContentHash: "", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), }); expect(result).toBeNull(); + expect(client.downloadFunctionRegistryScript).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/sdk/src/cli/commands/function/logs.ts b/packages/sdk/src/cli/commands/function/logs.ts index c108b63b2..bacf904e3 100644 --- a/packages/sdk/src/cli/commands/function/logs.ts +++ b/packages/sdk/src/cli/commands/function/logs.ts @@ -211,31 +211,20 @@ interface DownloadScriptForMappingOptions { * FunctionExecution.contentHash. When non-empty, the registry * download is pinned to this exact bundle so the stack trace maps * against the code that actually ran, regardless of subsequent - * redeploys. Empty on older servers that did not populate the - * field; in that case the caller falls back to a timestamp-based - * staleness check using `executionStartedAt`. + * redeploys. Empty values cannot be mapped safely. */ executionContentHash: string; - /** - * When the execution started. Used as a fallback staleness signal - * only when `executionContentHash` is empty: if the current registry - * entry's `updatedAt` is strictly newer, the downloaded bundle may - * differ from what was actually executed, so mapping is skipped to - * avoid misleading source locations. - */ - executionStartedAt: Date | null; } /** * Download a deployed function script for sourcemap mapping. Logs a * debug message on failure but never throws. Error display falls back - * to a plain-text format when the script cannot be retrieved or when - * the current registry entry is stale relative to the execution. + * to a plain-text format when the script cannot be retrieved. * * When `executionContentHash` is non-empty, the download is pinned to * that exact bundle so mapping stays correct across redeploys. When - * empty (older servers), falls back to comparing `registryUpdatedAt` - * against `executionStartedAt`. + * empty, mapping is skipped because the exact bundle cannot be + * identified. * * `FunctionExecution.scriptName` does not match the function registry * name directly; `scriptNameToRegistryName` translates between the two @@ -246,20 +235,12 @@ interface DownloadScriptForMappingOptions { * @param options.scriptName - Script name (matches FunctionExecution.scriptName) * @param options.executionType - Execution type used to discriminate registry name translation * @param options.executionContentHash - Content hash of the bundle that ran; pins the download when non-empty - * @param options.executionStartedAt - Execution start timestamp used as fallback staleness signal - * @returns Bundled script content, or null when unavailable / stale + * @returns Bundled script content, or null when unavailable */ export async function downloadScriptForMapping( options: DownloadScriptForMappingOptions, ): Promise { - const { - client, - workspaceId, - scriptName, - executionType, - executionContentHash, - executionStartedAt, - } = options; + const { client, workspaceId, scriptName, executionType, executionContentHash } = options; const registryName = scriptNameToRegistryName(scriptName, executionType); if (registryName == null) { logger.debug( @@ -268,44 +249,26 @@ export async function downloadScriptForMapping( return null; } - if (executionContentHash !== "") { - const pinned = await downloadFunctionScript({ - client, - workspaceId, - name: registryName, - contentHash: executionContentHash, - }); - if (pinned == null) { - logger.debug( - `Could not download pinned script "${scriptName}" (registry: "${registryName}", contentHash: "${executionContentHash}") for stack trace mapping; showing raw stack trace.`, - ); - return null; - } - return pinned.code; - } - - // Fallback for older servers that did not populate - // FunctionExecution.contentHash: download the current bundle and - // skip mapping if the registry was updated after the execution - // started. - const result = await downloadFunctionScript({ client, workspaceId, name: registryName }); - if (result == null) { + if (executionContentHash === "") { logger.debug( - `Could not download script "${scriptName}" (registry: "${registryName}") for stack trace mapping; showing raw stack trace.`, + `Function execution "${scriptName}" has no contentHash; skipping sourcemap mapping because the exact bundle cannot be identified.`, ); return null; } - if ( - executionStartedAt != null && - result.registryUpdatedAt != null && - result.registryUpdatedAt.getTime() > executionStartedAt.getTime() - ) { + + const pinned = await downloadFunctionScript({ + client, + workspaceId, + name: registryName, + contentHash: executionContentHash, + }); + if (pinned == null) { logger.debug( - `Registry script "${registryName}" was updated at ${result.registryUpdatedAt.toISOString()} after execution started at ${executionStartedAt.toISOString()}; skipping sourcemap mapping to avoid stale source locations.`, + `Could not download pinned script "${scriptName}" (registry: "${registryName}", contentHash: "${executionContentHash}") for stack trace mapping; showing raw stack trace.`, ); return null; } - return result.code; + return pinned.code; } export const logsCommand = defineAppCommand({ @@ -313,7 +276,7 @@ export const logsCommand = defineAppCommand({ description: "List or get function execution logs.", notes: `When viewing a specific execution that failed, the command displays error details with the stack trace mapped back to your original source files (clickable file links and code snippets, matching \`function test-run\` output). -Stack traces stay accurate even after later redeploys, because the trace is resolved against the exact build that produced the execution. If that build is no longer available, the command falls back to a plain-text error display.`, +Stack traces are mapped only when the execution includes a content hash for the exact build that ran. If the content hash is missing or the build is no longer available, the command falls back to a plain-text error display.`, examples: [ { cmd: "", @@ -332,16 +295,14 @@ Stack traces stay accurate even after later redeploys, because the trace is reso desc: "Get execution details as JSON", }, ], - args: z - .object({ - ...workspaceArgs, - ...pagedLogArgs, - "execution-id": arg(z.string().optional(), { - positional: true, - description: "Execution ID (if provided, shows details with logs)", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...pagedLogArgs, + "execution-id": arg(z.string().optional(), { + positional: true, + description: "Execution ID (if provided, shows details with logs)", + }), + }), run: async (args) => { const accessToken = await loadAccessToken({ profile: args.profile, @@ -378,7 +339,6 @@ Stack traces stay accurate even after later redeploys, because the trace is reso scriptName: detail.scriptName, executionType: execution.type, executionContentHash: execution.contentHash, - executionStartedAt: detail.startedAt, }) : null; printFunctionExecutionDetail({ detail, bundledCode }); diff --git a/packages/sdk/src/cli/commands/function/test-run.test.ts b/packages/sdk/src/cli/commands/function/test-run.test.ts index a9ae051b4..a9aa5a01f 100644 --- a/packages/sdk/src/cli/commands/function/test-run.test.ts +++ b/packages/sdk/src/cli/commands/function/test-run.test.ts @@ -9,7 +9,7 @@ import { loadMachineUserName } from "#/cli/shared/context"; import { executeScript } from "#/cli/shared/script-executor"; import { captureStderr, captureStdout } from "#/cli/shared/test-helpers/capture-output"; import { jsonMode } from "#/cli/shared/test-helpers/json-mode"; -import { resolveResolverArg, testRunCommand } from "./test-run"; +import { testRunCommand } from "./test-run"; vi.mock("#/cli/shared/config-loader", () => ({ loadConfig: vi.fn(), @@ -129,32 +129,3 @@ describe("function test-run --json", () => { ); }); }); - -describe("resolveResolverArg", () => { - const machineUser = { - name: "admin", - id: "00000000-0000-0000-0000-000000000000", - attributes: null, - attributeList: [], - }; - const rejectingSchema = { - parse: () => ({ issues: [{ message: "Required field is missing" }] }), - }; - - test("passes a null arg through for the server to report", () => { - const result = resolveResolverArg("null", rejectingSchema, machineUser, "ws-id"); - expect(result).toBe("null"); - }); - - test("unwraps the deprecated input wrapper when only the wrapped value parses", () => { - using _stderr = captureStderr(); - const inputSchema = { - parse: ({ value }: { value: unknown }) => - (value as Record).a === 1 - ? {} - : { issues: [{ message: "Required field is missing" }] }, - }; - const result = resolveResolverArg('{"input":{"a":1}}', inputSchema, machineUser, "ws-id"); - expect(result).toBe('{"a":1}'); - }); -}); diff --git a/packages/sdk/src/cli/commands/function/test-run.ts b/packages/sdk/src/cli/commands/function/test-run.ts index f6ae16ca5..e8f8b40e8 100644 --- a/packages/sdk/src/cli/commands/function/test-run.ts +++ b/packages/sdk/src/cli/commands/function/test-run.ts @@ -1,5 +1,5 @@ /** - * `tailor-sdk function test-run` command + * `tailor function test-run` command * * Bundles and executes a function on the Tailor Platform server * without deploying (applying) the application. @@ -25,12 +25,13 @@ import { executeScript } from "#/cli/shared/script-executor"; import { formatErrorWithSourcemap } from "#/cli/shared/stack-trace"; import { assertDefined } from "#/utils/assert"; import { bundleForTestRun, type ResolvedMachineUser } from "./bundle"; -import { detectFunctionType, type DetectedFunction } from "./detect"; -import type { TailorUser } from "#/runtime/types"; +import { detectFunctionType } from "./detect"; +import type { Jsonifiable } from "type-fest"; export const testRunCommand = defineAppCommand({ name: "test-run", description: "Run a function on the Tailor Platform server without deploying.", + // strip unknown keys args: z.object({ ...workspaceArgs, file: arg(z.string(), { @@ -60,8 +61,8 @@ export const testRunCommand = defineAppCommand({ When a \`.js\` file is provided, detection and bundling are skipped and the file is executed as-is. > [!WARNING] -> Workflow job \`.trigger()\` calls do not work in test-run mode. -> Triggered jobs are not executed; only the target job's \`body\` function runs in isolation.`, +> Workflow job \`.start()\` calls do not work in test-run mode. +> Started jobs are not executed; only the target job's \`body\` function runs in isolation.`, examples: [ { cmd: 'resolvers/add.ts --arg \'{"a":1,"b":2}\'', @@ -140,15 +141,11 @@ When a \`.js\` file is provided, detection and bundling are skipped and the file functionName = detected.name; logger.info(`Detected: ${styles.bold(detected.type)} ${styles.info(`"${detected.name}"`)}`); - if (detected.type === "resolver" && args.arg) { - if (!detected.hasInput) { - logger.warn( - '--arg is ignored because this resolver has no input schema. Define "input" in your resolver to use --arg.', - ); - args.arg = undefined; - } else if (detected.inputSchema) { - args.arg = resolveResolverArg(args.arg, detected.inputSchema, machineUser, workspaceId); - } + if (detected.type === "resolver" && args.arg && !detected.hasInput) { + logger.warn( + '--arg is ignored because this resolver has no input schema. Define "input" in your resolver to use --arg.', + ); + args.arg = undefined; } logger.info("Bundling..."); @@ -165,20 +162,31 @@ When a \`.js\` file is provided, detection and bundling are skipped and the file } // 5. Execute via TestExecScript - const authInvoker = create(AuthInvokerSchema, { + const invoker = create(AuthInvokerSchema, { namespace: authNamespace, machineUserName: machineUser.name, }); logger.info(`Executing on workspace ${styles.dim(workspaceId)}...`); + let parsedArg: Jsonifiable | undefined; + if (args.arg !== undefined) { + try { + parsedArg = JSON.parse(args.arg); + } catch (error) { + throw new Error(`Invalid --arg JSON: ${error instanceof Error ? error.message : error}`, { + cause: error, + }); + } + } + const result = await executeScript({ client, workspaceId, name: scriptName, code: bundledCode, - arg: args.arg, - invoker: authInvoker, + arg: parsedArg, + invoker, }); // 7. Display result @@ -284,7 +292,7 @@ async function resolveMachineUserName(options: ResolveMachineUserNameOptions): P } } throw new Error( - "Machine user is required. Provide --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, set a profile default with 'tailor-sdk profile update --machine-user ', or ensure tailor.config.ts has machine users configured.", + "Machine user is required. Provide --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, set a profile default with 'tailor profile update --machine-user ', or ensure tailor.config.ts has machine users configured.", ); } @@ -338,55 +346,3 @@ async function resolveMachineUser( return { name: machineUserName, id, attributes, attributeList }; } - -/** - * Resolve resolver arg format: detect and unwrap deprecated {"input":{...}} wrapper. - * Tries new format (arg = input fields) first via schema parse. - * If that fails and arg looks like old format, tries unwrapping. - * @param argStr - JSON string of the arg - * @param inputSchema - Pre-built schema object from detect (has .parse()) - * @param machineUser - Resolved machine user info - * @param workspaceId - Workspace ID - * @returns Resolved JSON string (unwrapped if old format) - */ -export function resolveResolverArg( - argStr: string, - inputSchema: NonNullable, - machineUser: ResolvedMachineUser, - workspaceId: string, -): string { - const parsed = JSON.parse(argStr); - const user = { - id: machineUser.id, - type: "machine_user" as const, - workspaceId, - attributes: machineUser.attributes as TailorUser["attributes"], - attributeList: machineUser.attributeList as TailorUser["attributeList"], - }; - - const newResult = inputSchema.parse({ value: parsed, data: parsed, user }); - if (!newResult.issues) { - return argStr; - } - - // New format failed — check if old format works - if ( - typeof parsed === "object" && - parsed !== null && - Object.keys(parsed).length === 1 && - parsed.input != null && - typeof parsed.input === "object" && - !Array.isArray(parsed.input) - ) { - const oldResult = inputSchema.parse({ value: parsed.input, data: parsed.input, user }); - if (!oldResult.issues) { - logger.warn( - '[DEPRECATED] Wrapping args with "input" key (e.g. {"input":{...}}) is deprecated. Pass input fields directly (e.g. {"a":1}). The "input" wrapper will be removed in v2.', - ); - return JSON.stringify(parsed.input); - } - } - - // Both failed — pass as-is, let server report the validation error - return argStr; -} diff --git a/packages/sdk/src/cli/commands/generate/index.ts b/packages/sdk/src/cli/commands/generate/index.ts index a3a44ecdb..94d2ae33a 100644 --- a/packages/sdk/src/cli/commands/generate/index.ts +++ b/packages/sdk/src/cli/commands/generate/index.ts @@ -6,24 +6,17 @@ import { defineAppCommand } from "#/cli/shared/command"; export const generateCommand = defineAppCommand({ name: "generate", description: "Generate files using Tailor configuration.", - args: z - .object({ - config: arg(z.string().default("tailor.config.ts"), { - alias: "c", - description: "Path to SDK config file", - }), - watch: arg(z.boolean().default(false), { - alias: "W", - description: "Watch for type/resolver changes and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + config: arg(z.string().default("tailor.config.ts"), { + alias: "c", + description: "Path to SDK config file", + }), + }), run: async (args) => { const { initTelemetry } = await import("#/cli/telemetry/index"); await initTelemetry(); await generate({ configPath: args.config, - watch: args.watch, }); }, }); diff --git a/packages/sdk/src/cli/commands/generate/options.ts b/packages/sdk/src/cli/commands/generate/options.ts index 8b332d56c..59fb9c6e9 100644 --- a/packages/sdk/src/cli/commands/generate/options.ts +++ b/packages/sdk/src/cli/commands/generate/options.ts @@ -1,4 +1,3 @@ export type GenerateOptions = { configPath?: string; - watch?: boolean; }; diff --git a/packages/sdk/src/cli/commands/generate/plugin-executor-generator.ts b/packages/sdk/src/cli/commands/generate/plugin-executor-generator.ts index 19311d8ed..a30e8a2e1 100644 --- a/packages/sdk/src/cli/commands/generate/plugin-executor-generator.ts +++ b/packages/sdk/src/cli/commands/generate/plugin-executor-generator.ts @@ -46,7 +46,7 @@ interface TypeImportInfo { * Generate TypeScript files for plugin-generated executors. * These files will be processed by the standard executor bundler. * @param executors - Array of plugin executor information - * @param outputDir - Base output directory (e.g., .tailor-sdk) + * @param outputDir - Base output directory (e.g., .tailor) * @param typeGenerationResult - Result from plugin type generation (for import resolution) * @param sourceTypeInfoMap - Map of source type names to their source info * @param configPath - Path to tailor.config.ts (used for resolving plugin import paths) @@ -88,7 +88,7 @@ export function generatePluginExecutorFiles( /** * Generate a single executor file. * @param info - Plugin executor metadata and definition - * @param outputDir - Base output directory (e.g., .tailor-sdk) + * @param outputDir - Base output directory (e.g., .tailor) * @param typeGenerationResult - Result from plugin type generation * @param sourceTypeInfoMap - Map of source type names to their source info * @param baseDirs - Base directories for resolving plugin import paths diff --git a/packages/sdk/src/cli/commands/generate/plugin-type-generator.ts b/packages/sdk/src/cli/commands/generate/plugin-type-generator.ts index ecc2da6b1..ee3bab176 100644 --- a/packages/sdk/src/cli/commands/generate/plugin-type-generator.ts +++ b/packages/sdk/src/cli/commands/generate/plugin-type-generator.ts @@ -34,7 +34,7 @@ function isFieldDefinition(value: unknown): value is FieldDefinition { * Generate TypeScript files for plugin-generated types. * These files export the type definition and can be imported by executor files. * @param types - Array of plugin type information - * @param outputDir - Base output directory (e.g., .tailor-sdk) + * @param outputDir - Base output directory (e.g., .tailor) * @returns Generation result with file paths */ export function generatePluginTypeFiles( @@ -106,7 +106,7 @@ function generateTypeFileContent(info: PluginGeneratedTypeInfo): string { */ import { db } from "@tailor-platform/sdk"; - export const ${variableName} = db.type(${JSON.stringify(type.name)}, ${fieldsCode}); + export const ${variableName} = db.table(${JSON.stringify(type.name)}, ${fieldsCode}); export type ${type.name} = typeof ${variableName}; `; diff --git a/packages/sdk/src/cli/commands/generate/seed/bundler.test.ts b/packages/sdk/src/cli/commands/generate/seed/bundler.test.ts index 71ff535b9..cb079b3ab 100644 --- a/packages/sdk/src/cli/commands/generate/seed/bundler.test.ts +++ b/packages/sdk/src/cli/commands/generate/seed/bundler.test.ts @@ -7,17 +7,17 @@ const TEST_BUNDLER_BASE = path.join(__dirname, "__test_bundler__"); describe("seed-bundler", () => { beforeEach(() => { - // Set TAILOR_SDK_OUTPUT_DIR to test directory so bundled output goes into test directory + // Set TAILOR_BUILD_OUTPUT_DIR to test directory so bundled output goes into test directory const testDir = path.join( TEST_BUNDLER_BASE, `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ); fs.mkdirSync(testDir, { recursive: true }); - process.env.TAILOR_SDK_OUTPUT_DIR = testDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = testDir; }); afterAll(() => { - delete process.env.TAILOR_SDK_OUTPUT_DIR; + delete process.env.TAILOR_BUILD_OUTPUT_DIR; try { fs.rmSync(TEST_BUNDLER_BASE, { recursive: true, force: true }); } catch { diff --git a/packages/sdk/src/cli/commands/generate/seed/bundler.ts b/packages/sdk/src/cli/commands/generate/seed/bundler.ts index dd6e26e3c..26b528f62 100644 --- a/packages/sdk/src/cli/commands/generate/seed/bundler.ts +++ b/packages/sdk/src/cli/commands/generate/seed/bundler.ts @@ -112,7 +112,7 @@ export async function bundleSeedScript( namespace: string, typeNames: string[], ): Promise { - // Output directory in .tailor-sdk (relative to project root) + // Output directory in .tailor (relative to project root) const outputDir = path.resolve(getDistDir(), "seed"); fs.mkdirSync(outputDir, { recursive: true }); diff --git a/packages/sdk/src/cli/commands/generate/service.test.ts b/packages/sdk/src/cli/commands/generate/service.test.ts index 00306f4c9..3c03b3fa9 100644 --- a/packages/sdk/src/cli/commands/generate/service.test.ts +++ b/packages/sdk/src/cli/commands/generate/service.test.ts @@ -3,19 +3,14 @@ import * as os from "node:os"; import * as path from "pathe"; import { describe, expect, test, beforeEach, afterEach, vi, afterAll } from "vitest"; import { defineApplication } from "#/cli/services/application"; -import { createResolver } from "#/configure/services/resolver/resolver"; -import { db } from "#/configure/services/tailordb/schema"; -import { t } from "#/configure/types/index"; -import { parseTypes } from "#/parser/service/tailordb/index"; -import { toSchemaOutputs } from "#/utils/test/internal"; +import { PluginManager } from "#/plugin/manager"; import { createGenerationManager } from "./service"; import type { Application } from "#/cli/services/application"; import type { TailorDBService } from "#/cli/services/tailordb/service"; -import type { LoadedConfig, Generator } from "#/cli/shared/config-loader"; -import type { TailorDBType } from "#/configure/services/tailordb/schema"; -import type { Resolver } from "#/types/resolver.generated"; +import type { LoadedConfig } from "#/cli/shared/config-loader"; +import type { TailorDBType } from "#/parser/service/tailordb/types"; +import type { Plugin } from "#/plugin/types"; -// ESM-safe explicit mock for Node's fs vi.mock("node:fs", () => { return { writeFile: vi.fn((_, _2, callback) => { @@ -51,55 +46,16 @@ vi.mock("#/cli/shared/logger", async (importOriginal) => { }; }); -class TestGenerator { - readonly id = "test-generator"; - readonly description = "Test generator for unit tests"; - readonly dependencies = ["tailordb", "resolver", "executor"] as const; - - async processType(args: { - type: TailorDBType; - namespace: string; - source: { filePath: string; exportName: string }; - }) { - return { name: args.type.name, processed: true, source: args.source }; - } - - async processResolver(args: { resolver: Resolver; namespace: string }) { - return { name: args.resolver.name, processed: true }; - } - - async processExecutor(executor: { name: T }) { - return { name: executor.name, processed: true }; - } - - async processTailorDBNamespace(args: { namespace: string; types: Record }) { - return { processed: true, count: Object.keys(args.types).length }; - } - - async processResolverNamespace(args: { namespace: string; resolvers: Record }) { - return { processed: true, count: Object.keys(args.resolvers).length }; - } - - async aggregate(args: { input: object; baseDir: string }) { - return { - files: [ - { - path: path.join(args.baseDir, "test-output.txt"), - content: `Input: ${JSON.stringify(args.input)}`, - }, - ], - }; - } -} - function loadedTailorDBService(namespace: string, typeNames: string[]): TailorDBService { - const types = Object.fromEntries(typeNames.map((typeName) => [typeName, {}])); + const types = Object.fromEntries( + typeNames.map((typeName) => [typeName, { name: typeName } as TailorDBType]), + ); const typeSourceInfo = Object.fromEntries( typeNames.map((typeName) => [ typeName, { filePath: `${namespace}/${typeName}.ts`, - exportName: typeName.toLowerCase(), + exportName: typeName, }, ]), ); @@ -125,35 +81,8 @@ function applicationWithTailorDBServices( }; } -function emptyGeneratorResult() { - return { - tailordbResults: {}, - resolverResults: {}, - tailordbNamespaceResults: {}, - resolverNamespaceResults: {}, - executorResults: {}, - }; -} - -function testResolver(name: string) { - return createResolver({ - name, - operation: "query", - body: () => ({ string: "" }), - output: t.object({ string: t.string() }), - }); -} - -function parsedTestTypes(typeNames: string[], namespace = "test-namespace", sourceInfo = {}) { - const types = Object.fromEntries(typeNames.map((name) => [name, db.type(name, {})])); - return parseTypes(toSchemaOutputs(types), namespace, sourceInfo); -} - describe("GenerationManager", () => { let tempDir: string; - // For test-only access to private members - // oxlint-disable-next-line no-explicit-any - let manager: any; let mockConfig: LoadedConfig; afterAll(() => { @@ -169,97 +98,99 @@ describe("GenerationManager", () => { db: { main: { files: ["src/types/*.ts"] } }, resolver: { main: { files: ["src/resolvers/*.ts"] } }, }; - - // for minimal mock - const application = defineApplication({ config: mockConfig }); - manager = createGenerationManager({ - application, - config: mockConfig, - // oxlint-disable-next-line no-explicit-any - generators: [new TestGenerator()] as any, - }); }); afterEach(() => { + vi.mocked(fs.writeFile).mockClear(); + vi.mocked(fs.mkdirSync).mockClear(); if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } }); describe("constructor", () => { - test("initializes correctly", () => { - expect(manager.application).toBeDefined(); + test("initializes without legacy generator API", () => { + const application = defineApplication({ config: mockConfig }); + const manager = createGenerationManager({ + application, + config: mockConfig, + }); + + expect(manager.application).toBe(application); expect(manager.baseDir).toContain("generated"); + expect(manager.services).toEqual({ tailordb: {}, resolver: {}, executor: {} }); + expect("generators" in manager).toBe(false); + expect("generatorResults" in manager).toBe(false); + expect("processGenerator" in manager).toBe(false); }); - test("base directory is created", () => { + test("creates base directory", () => { + const application = defineApplication({ config: mockConfig }); + createGenerationManager({ + application, + config: mockConfig, + }); + expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining("generated"), { recursive: true, }); }); }); - describe("generators", () => { - test("generators are passed correctly", () => { - expect(manager.generators.length).toBeGreaterThan(0); - }); - - test("receives custom generator", () => { - const customApp = defineApplication({ config: mockConfig }); - // For test-only - TestGenerator doesn't have brand symbol - // oxlint-disable-next-line no-explicit-any - const managerWithCustom: any = createGenerationManager({ - application: customApp, + describe("generate", () => { + test("executes complete generation process", async () => { + const application = defineApplication({ config: mockConfig }); + const manager = createGenerationManager({ + application, config: mockConfig, - generators: [new TestGenerator()] as unknown as Generator[], }); - expect( - managerWithCustom.generators.some((gen: { id: string }) => gen.id === "test-generator"), - ).toBe(true); - }); - }); - describe("generate", () => { - test("executes complete generation process", async () => { - await manager.generate(false); + await manager.generate(); - expect(manager.generators.length).toBeGreaterThan(0); expect(manager.services).toBeDefined(); }); - test("processes single application", async () => { - const singleAppConfig = { - ...mockConfig, - name: "single-app", - }; - // For test-only access to private members - const singleApp = defineApplication({ config: singleAppConfig }); - // oxlint-disable-next-line no-explicit-any - const singleAppManager: any = createGenerationManager({ - application: singleApp, - config: singleAppConfig, + test("runs plugin generation hooks after TailorDB load", async () => { + const onTailorDBReady = vi.fn().mockResolvedValue({ + files: [{ path: path.join(tempDir, "generated.txt"), content: "generated" }], }); - - await singleAppManager.generate(false); - expect(singleAppManager.services).toBeDefined(); - }); - - test("rejects duplicate TailorDB type names between namespaces", async () => { - const duplicateApp = applicationWithTailorDBServices(mockConfig, [ + const plugin: Plugin = { + id: "test-plugin", + description: "Test plugin", + onTailorDBReady, + }; + const application = applicationWithTailorDBServices(mockConfig, [ loadedTailorDBService("main", ["User"]), - loadedTailorDBService("analytics", ["User"]), ]); - const duplicateManager = createGenerationManager({ - application: duplicateApp, + const pluginManager = new PluginManager([plugin]); + const manager = createGenerationManager({ + application, config: mockConfig, + pluginManager, }); - await expect(duplicateManager.generate(false)).rejects.toThrow( - /Duplicate TailorDB type names detected/, + await manager.generate(); + + expect(onTailorDBReady).toHaveBeenCalledWith( + expect.objectContaining({ + tailordb: [ + expect.objectContaining({ + namespace: "main", + types: expect.objectContaining({ User: expect.objectContaining({ name: "User" }) }), + }), + ], + baseDir: expect.stringContaining("test-plugin"), + configPath: "tailor.config.ts", + }), + ); + expect(fs.writeFile).toHaveBeenCalledWith( + path.join(tempDir, "generated.txt"), + "generated", + expect.any(Function), ); }); - test("does not exit watch mode for duplicate TailorDB type names", async () => { + test("rejects duplicate TailorDB type names between namespaces", async () => { const duplicateApp = applicationWithTailorDBServices(mockConfig, [ loadedTailorDBService("main", ["User"]), loadedTailorDBService("analytics", ["User"]), @@ -269,457 +200,9 @@ describe("GenerationManager", () => { config: mockConfig, }); - await expect(duplicateManager.generate(true)).resolves.not.toThrow(); - }); - }); - - describe("runGenerators (via generate)", () => { - beforeEach(() => { - manager.services = { - tailordb: { - "test-namespace": { - types: parsedTestTypes(["TestType"]), - sourceInfo: {}, - }, - }, - resolver: { - "test-namespace": { - testResolver: testResolver("testResolver"), - }, - }, - executor: {}, - }; - }); - - test("processes all generators through generate method", async () => { - const testGenerator = manager.generators[0]; - using aggregateSpy = vi.spyOn(testGenerator, "aggregate"); - - await manager.generate(false); - - expect(aggregateSpy).toHaveBeenCalled(); - }); - - test("errors in generator processing do not affect others", async () => { - const errorGenerator = { - id: "error-generator", - description: "Error generator", - dependencies: ["tailordb", "resolver", "executor"] as const, - processType: vi - .fn() - .mockImplementation(() => Promise.reject(new Error("Type processing error"))), - processResolver: vi - .fn() - .mockImplementation(() => Promise.reject(new Error("Resolver processing error"))), - processExecutor: vi - .fn() - .mockImplementation(() => Promise.reject(new Error("Executor processing error"))), - aggregate: vi.fn().mockImplementation(() => Promise.resolve({ files: [] })), - }; - - manager.generators.push(errorGenerator); - - await manager.generate(false); - - expect(errorGenerator.aggregate).toHaveBeenCalled(); - }); - }); - - describe("processGenerator", () => { - let testGenerator: TestGenerator; - - beforeEach(() => { - testGenerator = new TestGenerator(); - manager.generators = [testGenerator]; - - manager.services.tailordb["test-namespace"] = { - types: parsedTestTypes(["TestType"]), - sourceInfo: {}, - pluginAttachments: new Map(), - }; - manager.services.resolver["test-namespace"] = { - testResolver: testResolver("testResolver"), - }; - }); - - test("complete processing of single generator", async () => { - Object.keys(manager.generatorResults).forEach((key) => { - delete manager.generatorResults[key]; - }); - - using processTypeSpy = vi.spyOn(testGenerator, "processType"); - using processResolverSpy = vi.spyOn(testGenerator, "processResolver"); - using aggregateSpy = vi.spyOn(testGenerator, "aggregate"); - - await manager.processGenerator(testGenerator); - - expect(processTypeSpy).toHaveBeenCalled(); - expect(processResolverSpy).toHaveBeenCalled(); - expect(aggregateSpy).toHaveBeenCalled(); - }); - - test("types and resolvers are processed in parallel", async () => { - Object.keys(manager.generatorResults).forEach((key) => { - delete manager.generatorResults[key]; - }); - - const start = Date.now(); - await manager.processGenerator(testGenerator); - const duration = Date.now() - start; - - expect(duration).toBeLessThan(1000); - }); - }); - - describe("processTailorDBNamespace", () => { - let testGenerator: TestGenerator; - - beforeEach(() => { - testGenerator = new TestGenerator(); - manager.generatorResults[testGenerator.id] = emptyGeneratorResult(); - }); - - test("processes all types", async () => { - using processTypeSpy = vi.spyOn(testGenerator, "processType"); - const parsedTypes = parsedTestTypes(["Type1", "Type2", "Type3"]); - - manager.generatorResults[testGenerator.id] = emptyGeneratorResult(); - - await manager.processTailorDBNamespace(testGenerator, "test-namespace", { - types: parsedTypes, - sourceInfo: {}, - pluginAttachments: new Map(), - }); - - expect(processTypeSpy).toHaveBeenCalledTimes(3); - expect( - manager.generatorResults[testGenerator.id].tailordbResults["test-namespace"], - ).toBeDefined(); - expect( - Object.keys(manager.generatorResults[testGenerator.id].tailordbResults["test-namespace"]), - ).toHaveLength(3); - }); - - test("does not error with empty types", async () => { - manager.generatorResults[testGenerator.id] = emptyGeneratorResult(); - - await expect( - manager.processTailorDBNamespace(testGenerator, "test-namespace", { - types: {}, - sourceInfo: {}, - pluginAttachments: new Map(), - }), - ).resolves.not.toThrow(); - }); - - test("sourceInfo is correctly passed to processType", async () => { - using processTypeSpy = vi.spyOn(testGenerator, "processType"); - const sourceInfo = { - TestType: { - filePath: "test.ts", - exportName: "TestType", - }, - }; - const parsedTypes = parsedTestTypes(["TestType"], "test-namespace", sourceInfo); - - manager.generatorResults[testGenerator.id] = emptyGeneratorResult(); - - await manager.processTailorDBNamespace(testGenerator, "test-namespace", { - types: parsedTypes, - sourceInfo, - pluginAttachments: new Map(), - }); - - expect(processTypeSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: expect.any(Object), - namespace: "test-namespace", - source: expect.objectContaining({ - filePath: "test.ts", - exportName: "TestType", - }), - plugins: [], - }), + await expect(duplicateManager.generate()).rejects.toThrow( + /Duplicate TailorDB type names detected/, ); }); }); - - describe("processResolverNamespace", () => { - let testGenerator: TestGenerator; - - beforeEach(() => { - testGenerator = new TestGenerator(); - manager.generatorResults[testGenerator.id] = emptyGeneratorResult(); - }); - - test("processes all resolvers", async () => { - using processResolverSpy = vi.spyOn(testGenerator, "processResolver"); - const resolvers = { - resolver1: testResolver("resolver1"), - resolver2: testResolver("resolver2"), - }; - - await manager.processResolverNamespace(testGenerator, "test-namespace", resolvers); - - expect(processResolverSpy).toHaveBeenCalledTimes(2); - expect( - manager.generatorResults[testGenerator.id].resolverResults["test-namespace"], - ).toBeDefined(); - expect( - Object.keys(manager.generatorResults[testGenerator.id].resolverResults["test-namespace"]), - ).toHaveLength(2); - }); - }); - - describe("aggregate", () => { - let testGenerator: TestGenerator; - - beforeEach(() => { - testGenerator = new TestGenerator(); - manager.generatorResults[testGenerator.id] = { - ...emptyGeneratorResult(), - tailordbNamespaceResults: { - "test-namespace": { types: "processed" }, - }, - resolverNamespaceResults: { - "test-namespace": { resolvers: "processed" }, - }, - }; - }); - - test("calls generator aggregate method", async () => { - using aggregateSpy = vi.spyOn(testGenerator, "aggregate"); - - await manager.aggregate(testGenerator); - - expect(aggregateSpy).toHaveBeenCalledWith({ - input: { - tailordb: [ - { - namespace: "test-namespace", - types: { types: "processed" }, - }, - ], - resolver: [ - { - namespace: "test-namespace", - resolvers: { resolvers: "processed" }, - }, - ], - executor: [], - auth: undefined, - }, - baseDir: expect.stringContaining(testGenerator.id), - configPath: expect.any(String), - }); - }); - - test("writes files correctly", async () => { - await manager.aggregate(testGenerator); - - expect(fs.writeFile).toHaveBeenCalled(); - expect(fs.mkdirSync).toHaveBeenCalled(); - }); - - test("parallel writing of multiple files", async () => { - vi.mocked(fs.writeFile).mockClear(); - - const multiFileGenerator = { - id: testGenerator.id, - description: testGenerator.description, - dependencies: testGenerator.dependencies, - aggregate: vi.fn().mockResolvedValue({ - files: [ - { path: "/test/file1.txt", content: "content1" }, - { path: "/test/file2.txt", content: "content2" }, - { path: "/test/file3.txt", content: "content3" }, - ], - }), - }; - - manager.generatorResults[multiFileGenerator.id] = emptyGeneratorResult(); - - await manager.aggregate(multiFileGenerator); - - expect(fs.writeFile).toHaveBeenCalledTimes(3); - }); - - test("handles file write errors", async () => { - const writeFileError = new Error("Write permission denied"); - vi.mocked(fs.writeFile).mockImplementationOnce((_path, _content, callback) => { - callback(writeFileError); - }); - - const errorGenerator = { - id: testGenerator.id, - description: testGenerator.description, - dependencies: testGenerator.dependencies, - aggregate: vi.fn().mockResolvedValue({ - files: [{ path: "/test/error.txt", content: "content" }], - }), - }; - - manager.generatorResults[errorGenerator.id] = emptyGeneratorResult(); - - await expect(manager.aggregate(errorGenerator)).rejects.toThrow("Write permission denied"); - }); - }); - - describe("watch", () => { - test("watch method exists", () => { - expect(typeof manager.watch).toBe("function"); - }); - - test("application has tailorDBServices for watch", () => { - expect(manager.application.tailorDBServices).toBeDefined(); - expect(manager.application.tailorDBServices.length).toBeGreaterThan(0); - expect(manager.application.tailorDBServices[0].namespace).toBe("main"); - expect(manager.application.tailorDBServices[0].config.files).toEqual(["src/types/*.ts"]); - }); - - test("application has resolverServices for watch", () => { - expect(manager.application.resolverServices).toBeDefined(); - expect(manager.application.resolverServices.length).toBeGreaterThan(0); - expect(manager.application.resolverServices[0].namespace).toBe("main"); - expect(manager.application.resolverServices[0].config.files).toEqual(["src/resolvers/*.ts"]); - }); - }); -}); - -describe("generate function", () => { - let mockConfig: LoadedConfig; - - beforeEach(() => { - mockConfig = { - name: "test-workspace", - path: "tailor.config.ts", - }; - }); - - test("generate does not automatically call watch", async () => { - const app = defineApplication({ config: mockConfig }); - const manager = createGenerationManager({ application: app, config: mockConfig }); - await expect(manager.generate(false)).resolves.not.toThrow(); - expect(manager.application).toBeDefined(); - }); -}); - -describe("Integration Tests", () => { - let tempDir: string; - let fullConfig: LoadedConfig; - - beforeEach(async () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "integration-test-")); - - fullConfig = { - name: "testApp", - path: "tailor.config.ts", - db: { - main: { - files: [path.join(tempDir, "types/*.ts")], - }, - }, - resolver: { - main: { - files: [path.join(tempDir, "resolvers/*.ts")], - }, - }, - }; - }); - - afterEach(() => { - if (fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); - - test("complete integration test with multiple generators", async () => { - const gen1 = new TestGenerator(); - const gen2 = new TestGenerator(); - // For test-only access to private members - // oxlint-disable-next-line no-explicit-any - const generators: any[] = [gen1, gen2]; - const integrationApp = defineApplication({ config: fullConfig }); - // oxlint-disable-next-line no-explicit-any - const manager: any = createGenerationManager({ - application: integrationApp, - config: fullConfig, - generators, - }); - - await expect(manager.generate(false)).resolves.not.toThrow(); - - expect(manager.generators.length).toBe(2); - expect(manager.generators.every((g: unknown) => g instanceof TestGenerator)).toBe(true); - }); - - test("integration test for error recovery and performance", async () => { - const errorApp = defineApplication({ config: fullConfig }); - const manager = createGenerationManager({ application: errorApp, config: fullConfig }); - - const start = Date.now(); - await manager.generate(false); - const duration = Date.now() - start; - - expect(duration).toBeLessThan(5000); - }); - - describe("Memory Management", () => { - test("no memory leak with large data processing", async () => { - // For test-only - TestGenerator doesn't have brand symbol - // oxlint-disable-next-line no-explicit-any - const largeGenerators: any[] = Array(10) - .fill(0) - .map(() => new TestGenerator()); - - // For test-only access to private members - const memApp = defineApplication({ config: fullConfig }); - // oxlint-disable-next-line no-explicit-any - const manager: any = createGenerationManager({ - application: memApp, - config: fullConfig, - generators: largeGenerators, - }); - - // Create large application data structure - manager.services = { - tailordb: {}, - resolver: {}, - executor: {}, - }; - - // Add multiple namespaces - Array(10) - .fill(0) - .forEach((_, nsIdx) => { - const namespace = `namespace-${nsIdx}`; - - // Add types to namespace - const types: Record = {}; - Array(50) - .fill(0) - .forEach((_, typeIdx) => { - types[`Type${nsIdx}_${typeIdx}`] = db.type(`Type${nsIdx}_${typeIdx}`, {}); - }); - - const parsedTypes = parseTypes(toSchemaOutputs(types), namespace, {}); - - manager.services.tailordb[namespace] = { - types: parsedTypes, - sourceInfo: {}, - }; - - // Add resolvers to namespace - manager.services.resolver[namespace] = {}; - Array(10) - .fill(0) - .forEach((_, resolverIdx) => { - manager.services.resolver[namespace][`resolver${nsIdx}_${resolverIdx}`] = - testResolver(`resolver${nsIdx}_${resolverIdx}`); - }); - }); - - await expect(manager.generate(false)).resolves.not.toThrow(); - }); - }); }); diff --git a/packages/sdk/src/cli/commands/generate/service.ts b/packages/sdk/src/cli/commands/generate/service.ts index 703a014bd..7a6ecacb4 100644 --- a/packages/sdk/src/cli/commands/generate/service.ts +++ b/packages/sdk/src/cli/commands/generate/service.ts @@ -1,15 +1,5 @@ -import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as path from "pathe"; -import { - type AnyCodeGenerator, - type TailorDBNamespaceResult, - type ResolverNamespaceResult, - type GeneratorAuthInput, - type GeneratorResult, - type DependencyKind, - hasDependency, -} from "#/cli/commands/generate/types"; import { defineApplication, generatePluginFilesIfNeeded, @@ -17,20 +7,17 @@ import { } from "#/cli/services/application"; import { createExecutorService } from "#/cli/services/executor/service"; import { assertUniqueLocalTailorDBTypeNames } from "#/cli/services/tailordb/type-name-validation"; -import { loadConfig, type LoadedConfig, type Generator } from "#/cli/shared/config-loader"; +import { loadConfig, type LoadedConfig } from "#/cli/shared/config-loader"; import { getDistDir } from "#/cli/shared/dist-dir"; import { logger, styles } from "#/cli/shared/logger"; import { generateUserTypes } from "#/cli/shared/type-generator"; import { withSpan } from "#/cli/telemetry/index"; import { PluginManager } from "#/plugin/manager"; import { assertDefined } from "#/utils/assert"; -import { createDependencyWatcher, type DependencyWatcher } from "./watch"; -import type { - TypeSourceInfo, - TypeSourceInfoEntry, - TailorDBType, -} from "#/parser/service/tailordb/types"; +import type { TypeSourceInfo, TailorDBType } from "#/parser/service/tailordb/types"; import type { + GeneratorAuthInput, + GeneratorResult, TailorDBNamespaceData, ResolverNamespaceData, Plugin, @@ -40,8 +27,6 @@ import type { Executor } from "#/types/executor.generated"; import type { Resolver } from "#/types/resolver.generated"; import type { GenerateOptions } from "./options"; -export type { CodeGenerator } from "#/cli/commands/generate/types"; - type TypeInfo = { types: Record; sourceInfo: TypeSourceInfo; @@ -54,57 +39,28 @@ type TypeInfo = { export type GenerationManager = { readonly application: Application; readonly baseDir: string; - readonly generators: Generator[]; readonly services: { tailordb: Record; resolver: Record>; executor: Record; }; - readonly generatorResults: GeneratorResults; - processGenerator: (gen: AnyCodeGenerator) => Promise; - processTailorDBNamespace: ( - gen: AnyCodeGenerator, - namespace: string, - typeInfo: TypeInfo, - ) => Promise; - processResolverNamespace: ( - gen: AnyCodeGenerator, - namespace: string, - resolvers: Record, - ) => Promise; - processExecutors: (gen: AnyCodeGenerator) => Promise; - aggregate: (gen: AnyCodeGenerator) => Promise; - generate: (watch: boolean) => Promise; - watch: () => Promise; + generate: () => Promise; }; -type GeneratorResults = Record< - /* generator */ string, - { - tailordbResults: Record>; - resolverResults: Record>; - tailordbNamespaceResults: Record; - resolverNamespaceResults: Record; - executorResults: Record; - } ->; - /** * Creates a generation manager. * @param params - Parameters for creating the generation manager * @param params.application - Application instance to generate code for * @param params.config - Loaded configuration - * @param params.generators - Code generators to run * @param params.pluginManager - Plugin manager for processing plugins * @returns GenerationManager instance */ export function createGenerationManager(params: { application: Application; config: LoadedConfig; - generators?: Generator[]; pluginManager?: PluginManager; }): GenerationManager { - const { application, config, generators = [], pluginManager } = params; + const { application, config, pluginManager } = params; const baseDir = path.join(getDistDir(), "generated"); fs.mkdirSync(baseDir, { recursive: true }); @@ -114,17 +70,9 @@ export function createGenerationManager(params: { executor: Record; } = { tailordb: {}, resolver: {}, executor: {} }; - let watcher: DependencyWatcher | null = null; - const generatorResults: GeneratorResults = {}; - // Get plugins that have generation hooks const generationPlugins = pluginManager?.getPluginsWithGenerationHooks() ?? []; - // Returns generators that subscribe to the given dependency phase - function getReadyGenerators(dep: DependencyKind): Generator[] { - return generators.filter((g) => (g as AnyCodeGenerator).dependencies.includes(dep)); - } - function getAuthInput(): GeneratorAuthInput | undefined { const authService = application.authService; if (!authService) return undefined; @@ -146,199 +94,6 @@ export function createGenerationManager(params: { }; } - // ========================================================================= - // Generator processing (unchanged - per-type/perNS/aggregate pipeline) - // ========================================================================= - - async function processTailorDBNamespace( - gen: AnyCodeGenerator, - namespace: string, - typeInfo: TypeInfo, - ): Promise { - const results = assertDefined( - generatorResults[gen.id], - `generator result not initialized for ${gen.id}`, - ); - results.tailordbResults[namespace] = {}; - - // Check if generator has processType method - if (!gen.processType) { - return; - } - - const processType = gen.processType; - await Promise.allSettled( - Object.entries(typeInfo.types).map(async ([typeName, type]) => { - try { - assertDefined( - results.tailordbResults[namespace], - `tailordb results not initialized for namespace ${namespace}`, - )[typeName] = await processType({ - type, - namespace, - source: typeInfo.sourceInfo[typeName] as TypeSourceInfoEntry, - plugins: typeInfo.pluginAttachments.get(typeName) ?? [], - }); - } catch (error) { - logger.error( - `Error processing type ${styles.bold(typeName)} in ${namespace} with generator ${gen.id}`, - ); - logger.error(String(error)); - } - }), - ); - - // Process namespace summary if available - if ("processTailorDBNamespace" in gen && typeof gen.processTailorDBNamespace === "function") { - try { - results.tailordbNamespaceResults[namespace] = await gen.processTailorDBNamespace({ - namespace, - types: results.tailordbResults[namespace], - }); - } catch (error) { - logger.error( - `Error processing TailorDB namespace ${styles.bold(namespace)} with generator ${gen.id}`, - ); - logger.error(String(error)); - } - } else { - results.tailordbNamespaceResults[namespace] = results.tailordbResults[namespace]; - } - } - - async function processResolverNamespace( - gen: AnyCodeGenerator, - namespace: string, - resolvers: Record, - ): Promise { - const results = assertDefined( - generatorResults[gen.id], - `generator result not initialized for ${gen.id}`, - ); - results.resolverResults[namespace] = {}; - - // Check if generator has processResolver method - if (!gen.processResolver) { - return; - } - - const processResolver = gen.processResolver; - // Process individual resolvers - await Promise.allSettled( - Object.entries(resolvers).map(async ([resolverName, resolver]) => { - try { - assertDefined( - results.resolverResults[namespace], - `resolver results not initialized for namespace ${namespace}`, - )[resolverName] = await processResolver({ - resolver, - namespace, - }); - } catch (error) { - logger.error( - `Error processing resolver ${styles.bold(resolverName)} in ${namespace} with generator ${gen.id}`, - ); - logger.error(String(error)); - } - }), - ); - - // Process namespace summary if available - if ("processResolverNamespace" in gen && typeof gen.processResolverNamespace === "function") { - try { - results.resolverNamespaceResults[namespace] = await gen.processResolverNamespace({ - namespace, - resolvers: results.resolverResults[namespace], - }); - } catch (error) { - logger.error( - `Error processing Resolver namespace ${styles.bold(namespace)} with generator ${gen.id}`, - ); - logger.error(String(error)); - } - } else { - results.resolverNamespaceResults[namespace] = results.resolverResults[namespace]; - } - } - - async function processExecutors(gen: AnyCodeGenerator): Promise { - const results = assertDefined( - generatorResults[gen.id], - `generator result not initialized for ${gen.id}`, - ); - - // Check if generator has processExecutor method - if (!gen.processExecutor) { - return; - } - - const processExecutor = gen.processExecutor; - // Process individual executors - await Promise.allSettled( - Object.entries(services.executor).map(async ([executorId, executor]) => { - try { - results.executorResults[executorId] = await processExecutor(executor); - } catch (error) { - logger.error( - `Error processing executor ${styles.bold(executor.name)} with generator ${gen.id}`, - ); - logger.error(String(error)); - } - }), - ); - } - - async function aggregate(gen: AnyCodeGenerator): Promise { - const results = assertDefined( - generatorResults[gen.id], - `generator result not initialized for ${gen.id}`, - ); - - const tailordbResults: TailorDBNamespaceResult[] = []; - const resolverResults: ResolverNamespaceResult[] = []; - - // Collect TailorDB namespace results - for (const [namespace, types] of Object.entries(results.tailordbNamespaceResults)) { - tailordbResults.push({ - namespace, - types, - }); - } - - // Collect Resolver namespace results - for (const [namespace, resolvers] of Object.entries(results.resolverNamespaceResults)) { - resolverResults.push({ - namespace, - resolvers, - }); - } - - // Build input based on generator dependencies - const input: Record = { - auth: getAuthInput(), - }; - - if (hasDependency(gen, "tailordb")) { - input.tailordb = tailordbResults; - } - if (hasDependency(gen, "resolver")) { - input.resolver = resolverResults; - } - if (hasDependency(gen, "executor")) { - input.executor = Object.values(results.executorResults); - } - - // Call generator's aggregate method - const result = await gen.aggregate({ - input: input as Parameters[0]["input"], - baseDir: path.join(baseDir, gen.id), - configPath: config.path, - }); - - // Write generated files - await writeGeneratedFiles(gen.id, result); - } - // ========================================================================= // Plugin phase-complete hook runner // ========================================================================= @@ -436,11 +191,9 @@ export function createGenerationManager(params: { * Each hook runs at its natural pipeline phase, ensuring outputs from earlier * phases are available when later phases load resolvers/executors. * @param hookName - Name of the hook to call - * @param watch - Whether running in watch mode (suppresses throws) */ async function runPluginHook( hookName: "onTailorDBReady" | "onResolverReady" | "onExecutorReady", - watch: boolean, ): Promise { const plugins = generationPlugins.filter((p) => p[hookName] != null); if (plugins.length === 0) return; @@ -451,17 +204,13 @@ export function createGenerationManager(params: { } catch (error) { logger.error(`Error processing plugin ${styles.bold(plugin.id)} (${hookName})`); logger.error(String(error)); - if (!watch) { - throw error; - } + throw error; } }), ); - if (!watch) { - const failures = results.filter((r): r is PromiseRejectedResult => r.status === "rejected"); - if (failures.length > 0) { - throw new AggregateError(failures.map((f) => f.reason)); - } + const failures = results.filter((r): r is PromiseRejectedResult => r.status === "rejected"); + if (failures.length > 0) { + throw new AggregateError(failures.map((f) => f.reason)); } } @@ -471,8 +220,8 @@ export function createGenerationManager(params: { /** * Write generated files to disk. - * @param sourceId - Generator or plugin ID for logging - * @param result - Generator result containing files to write + * @param sourceId - Plugin ID for logging + * @param result - Generation result containing files to write */ async function writeGeneratedFiles(sourceId: string, result: GeneratorResult): Promise { await Promise.all( @@ -518,126 +267,12 @@ export function createGenerationManager(params: { ); } - // ========================================================================= - // Generator orchestration - // ========================================================================= - - async function processGenerator(gen: AnyCodeGenerator): Promise { - generatorResults[gen.id] = { - tailordbResults: {}, - resolverResults: {}, - tailordbNamespaceResults: {}, - resolverNamespaceResults: {}, - executorResults: {}, - }; - - // Process TailorDB if generator has tailordb dependency - if (hasDependency(gen, "tailordb")) { - for (const [namespace, types] of Object.entries(services.tailordb)) { - await processTailorDBNamespace(gen, namespace, types); - } - } - - // Process Resolver if generator has resolver dependency - if (hasDependency(gen, "resolver")) { - for (const [namespace, resolvers] of Object.entries(services.resolver)) { - await processResolverNamespace(gen, namespace, resolvers); - } - } - - // Process Executors if generator has executor dependency - if (hasDependency(gen, "executor")) { - await processExecutors(gen); - } - - // Aggregate all results - await aggregate(gen); - } - - async function runGenerators(gens: Generator[], watch: boolean): Promise { - const results = await Promise.allSettled( - gens.map(async (gen) => { - await withSpan(`generate.generator.${gen.id}`, async () => { - try { - await processGenerator(gen as AnyCodeGenerator); - } catch (error) { - logger.error(`Error processing generator ${styles.bold(gen.id)}`); - logger.error(String(error)); - if (!watch) { - throw error; - } - } - }); - }), - ); - if (!watch) { - const failures = results.filter((r): r is PromiseRejectedResult => r.status === "rejected"); - if (failures.length > 0) { - throw new AggregateError(failures.map((f) => f.reason)); - } - } - } - - async function restartWatchProcess(): Promise { - logger.newline(); - logger.info("Restarting watch process to clear module cache...", { - mode: "stream", - }); - logger.newline(); - - // Clean up watcher first - if (watcher) { - await watcher.stop(); - } - - // Spawn a new process with the same arguments - const args = process.argv.slice(2); - const env = { - ...process.env, - TAILOR_WATCH_GENERATION: ( - parseInt(process.env.TAILOR_WATCH_GENERATION || "0", 10) + 1 - ).toString(), - }; - - const child = spawn( - assertDefined(process.argv[0], "argv[0] missing"), - [assertDefined(process.argv[1], "argv[1] missing"), ...args], - { - stdio: "inherit", - env, - detached: false, - }, - ); - - // Forward signals to child - const forwardSignal = (signal: NodeJS.Signals) => { - child.kill(signal); - }; - - process.on("SIGINT", forwardSignal); - process.on("SIGTERM", forwardSignal); - - // Wait for child to exit, then exit parent - child.on("exit", (code) => { - process.exit(code || 0); - }); - - // Don't exit immediately - let child handle everything - } - return { application, baseDir, - generators, services, - generatorResults, - processGenerator, - processTailorDBNamespace, - processResolverNamespace, - processExecutors, - aggregate, - - async generate(watch: boolean): Promise { + + async generate(): Promise { logger.newline(); logger.log(`Generation for application: ${styles.highlight(application.config.name)}`); @@ -664,9 +299,7 @@ export function createGenerationManager(params: { } catch (error) { logger.error(`Error loading types for TailorDB service ${styles.bold(namespace)}`); logger.error(String(error)); - if (!watch) { - throw error; - } + throw error; } }); } @@ -677,9 +310,7 @@ export function createGenerationManager(params: { } catch (error) { logger.error("Error validating TailorDB type names"); logger.error(String(error)); - if (!watch) { - throw error; - } + throw error; } }); @@ -715,15 +346,11 @@ export function createGenerationManager(params: { logger.newline(); } - // Run generators + plugin hooks for onTailorDBReady - const readyAfterTailorDB = getReadyGenerators("tailordb"); + // Run plugin hooks for onTailorDBReady const hasOnTailorDBReady = generationPlugins.some((p) => p.onTailorDBReady != null); - if (readyAfterTailorDB.length > 0 || hasOnTailorDBReady) { + if (hasOnTailorDBReady) { await withSpan("generate.onTailorDBReady", async () => { - await Promise.all([ - runGenerators(readyAfterTailorDB, watch), - runPluginHook("onTailorDBReady", watch), - ]); + await runPluginHook("onTailorDBReady"); }); logger.newline(); } @@ -745,23 +372,17 @@ export function createGenerationManager(params: { `Error loading resolvers for Resolver service ${styles.bold(namespace)}`, ); logger.error(String(error)); - if (!watch) { - throw error; - } + throw error; } }); } }); - // Run generators + plugin hooks for onResolverReady - const readyAfterResolvers = getReadyGenerators("resolver"); + // Run plugin hooks for onResolverReady const hasOnResolverReady = generationPlugins.some((p) => p.onResolverReady != null); - if (readyAfterResolvers.length > 0 || hasOnResolverReady) { + if (hasOnResolverReady) { await withSpan("generate.onResolversReady", async () => { - await Promise.all([ - runGenerators(readyAfterResolvers, watch), - runPluginHook("onResolverReady", watch), - ]); + await runPluginHook("onResolverReady"); }); logger.newline(); } @@ -782,76 +403,29 @@ export function createGenerationManager(params: { }); }); - // Run generators + plugin hooks for onExecutorReady - const readyAfterExecutors = getReadyGenerators("executor"); + // Run plugin hooks for onExecutorReady const hasOnExecutorReady = generationPlugins.some((p) => p.onExecutorReady != null); - if (readyAfterExecutors.length > 0 || hasOnExecutorReady) { + if (hasOnExecutorReady) { await withSpan("generate.onExecutorsReady", async () => { - await Promise.all([ - runGenerators(readyAfterExecutors, watch), - runPluginHook("onExecutorReady", watch), - ]); + await runPluginHook("onExecutorReady"); }); logger.newline(); } }, - - async watch(): Promise { - watcher = createDependencyWatcher(); - - // Set up restart callback - watcher.setRestartCallback(() => { - restartWatchProcess(); - }); - - // Watch groups' relative patterns resolve against the config file's own - // directory, not process.cwd() — matching how services/bundlers resolve - // `files` patterns, so watch mode stays correct in multi-config setups. - const configDir = path.dirname(config.path); - - // Watch config file - await watcher.addWatchGroup("Config", [config.path], configDir); - - // Watch application services - const app = application; - - // Watch TailorDB services - for (const db of app.tailorDBServices) { - const dbNamespace = db.namespace; - await watcher.addWatchGroup(`TailorDB/${dbNamespace}`, db.config.files, configDir); - } - - // Watch Resolver services - for (const resolverService of app.resolverServices) { - const resolverNamespace = resolverService.namespace; - await watcher.addWatchGroup( - `Resolver/${resolverNamespace}`, - resolverService["config"].files, - configDir, - ); - } - - // Keep the process running - await new Promise(() => {}); - }, }; } /** - * Run code generation using the Tailor configuration and generators. + * Run code generation using the Tailor configuration. * @param options - Generation options - * @returns Promise that resolves when generation (and watch, if enabled) completes + * @returns Promise that resolves when generation completes */ export async function generate(options?: GenerateOptions) { return withSpan("generate", async (rootSpan) => { // Load and validate options - const { config, generators, plugins } = await withSpan("generate.loadConfig", async () => { + const { config, plugins } = await withSpan("generate.loadConfig", async () => { return loadConfig(options?.configPath); }); - const watch = options?.watch ?? false; - - rootSpan.setAttribute("generate.watch", watch); - rootSpan.setAttribute("generate.generators.count", generators.length); // Generate user types from loaded config await withSpan("generate.generateUserTypes", async () => @@ -869,10 +443,7 @@ export async function generate(options?: GenerateOptions) { rootSpan.setAttribute("app.name", application.config.name); - const manager = createGenerationManager({ application, config, generators, pluginManager }); - await manager.generate(watch); - if (watch) { - await manager.watch(); - } + const manager = createGenerationManager({ application, config, pluginManager }); + await manager.generate(); }); } diff --git a/packages/sdk/src/cli/commands/generate/types.test.ts b/packages/sdk/src/cli/commands/generate/types.test.ts deleted file mode 100644 index c9f23ac35..000000000 --- a/packages/sdk/src/cli/commands/generate/types.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, test, expectTypeOf } from "vitest"; -import { - hasDependency, - type CodeGenerator, - type TailorDBGenerator, - type ResolverGenerator, - type ExecutorGenerator, - type TailorDBResolverGenerator, - type FullCodeGenerator, - type AnyCodeGenerator, - type DependencyKind, -} from "#/cli/commands/generate/types"; -import type { CodeGeneratorBase } from "#/plugin/types"; - -describe("Generator type compatibility", () => { - describe("TailorDBGenerator", () => { - test("should have tailordb dependency", () => { - expectTypeOf().toEqualTypeOf(); - }); - - test("should have processType method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should have aggregate method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should not have processResolver or processExecutor", () => { - type Keys = keyof TailorDBGenerator; - expectTypeOf<"processResolver">().not.toEqualTypeOf(); - expectTypeOf<"processExecutor">().not.toEqualTypeOf(); - }); - }); - - describe("ResolverGenerator", () => { - test("should have resolver dependency", () => { - expectTypeOf().toEqualTypeOf(); - }); - - test("should have processResolver method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should have aggregate method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should not have processType or processExecutor", () => { - type Keys = keyof ResolverGenerator; - expectTypeOf<"processType">().not.toEqualTypeOf(); - expectTypeOf<"processExecutor">().not.toEqualTypeOf(); - }); - }); - - describe("ExecutorGenerator", () => { - test("should have executor dependency", () => { - expectTypeOf().toEqualTypeOf(); - }); - - test("should have processExecutor method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should have aggregate method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should not have processType or processResolver", () => { - type Keys = keyof ExecutorGenerator; - expectTypeOf<"processType">().not.toEqualTypeOf(); - expectTypeOf<"processResolver">().not.toEqualTypeOf(); - }); - }); - - describe("TailorDBResolverGenerator", () => { - test("should have tailordb and resolver dependencies", () => { - expectTypeOf().toEqualTypeOf< - readonly ["tailordb", "resolver"] - >(); - }); - - test("should have both processType and processResolver methods", () => { - expectTypeOf().toBeFunction(); - expectTypeOf().toBeFunction(); - }); - - test("should not have processExecutor", () => { - type Keys = keyof TailorDBResolverGenerator; - expectTypeOf<"processExecutor">().not.toEqualTypeOf(); - }); - }); - - describe("FullCodeGenerator", () => { - test("should have all dependencies", () => { - expectTypeOf().toEqualTypeOf< - readonly ["tailordb", "resolver", "executor"] - >(); - }); - - test("should have all process methods", () => { - expectTypeOf().toBeFunction(); - expectTypeOf().toBeFunction(); - expectTypeOf().toBeFunction(); - }); - - test("should have aggregate method", () => { - expectTypeOf().toBeFunction(); - }); - }); - - describe("AnyCodeGenerator", () => { - test("should have optional process methods", () => { - type ProcessType = AnyCodeGenerator["processType"]; - type ProcessResolver = AnyCodeGenerator["processResolver"]; - type ProcessExecutor = AnyCodeGenerator["processExecutor"]; - - // These methods are optional (can be undefined or a function) - expectTypeOf().toExtend(); - expectTypeOf().toExtend(); - expectTypeOf().toExtend(); - }); - - test("should be assignable to CodeGeneratorBase", () => { - expectTypeOf().toExtend(); - }); - }); - - describe("hasDependency runtime utility", () => { - test("should return true when dependency exists", () => { - const gen = { dependencies: ["tailordb", "resolver"] as const }; - expect(hasDependency(gen, "tailordb")).toBe(true); - expect(hasDependency(gen, "resolver")).toBe(true); - }); - - test("should return false when dependency does not exist", () => { - const gen = { dependencies: ["tailordb"] as const }; - expect(hasDependency(gen, "resolver")).toBe(false); - expect(hasDependency(gen, "executor")).toBe(false); - }); - }); - - describe("CodeGenerator generic type", () => { - test("should correctly infer dependencies from type parameter", () => { - type TestGen = CodeGenerator; - expectTypeOf().toEqualTypeOf(); - }); - - test("should be compatible with readonly dependency arrays", () => { - type ReadonlyDeps = readonly DependencyKind[]; - const deps: ReadonlyDeps = ["tailordb", "resolver"]; - expect(deps).toContain("tailordb"); - }); - }); -}); diff --git a/packages/sdk/src/cli/commands/generate/types.ts b/packages/sdk/src/cli/commands/generate/types.ts deleted file mode 100644 index 1a392d0df..000000000 --- a/packages/sdk/src/cli/commands/generate/types.ts +++ /dev/null @@ -1,323 +0,0 @@ -import type { TailorDBType, TypeSourceInfoEntry } from "#/parser/service/tailordb/types"; -import type { PluginAttachment } from "#/plugin/types"; -import type { IdProvider as IdProviderConfig, OAuth2Client } from "#/types/auth.generated"; -import type { Executor } from "#/types/executor.generated"; -import type { Resolver } from "#/types/resolver.generated"; - -export type { PluginAttachment } from "#/plugin/types"; - -// ======================================== -// Basic types -// ======================================== - -interface GeneratedFile { - path: string; - content: string; - skipIfExists?: boolean; // default: false - executable?: boolean; // default: false - if true, sets chmod +x -} - -export interface GeneratorResult { - files: GeneratedFile[]; - errors?: string[]; -} - -// Namespace results for TailorDB -export interface TailorDBNamespaceResult { - namespace: string; - types: T; -} - -// Namespace results for Resolver -export interface ResolverNamespaceResult { - namespace: string; - resolvers: R; -} - -// Auth configuration for generators -export interface GeneratorAuthInput { - name: string; - userProfile?: { - typeName: string; - namespace: string; - usernameField: string; - }; - machineUsers?: Record }>; - oauth2Clients?: Record; - idProvider?: IdProviderConfig; -} - -// ======================================== -// Dependency types -// ======================================== - -export type DependencyKind = "tailordb" | "resolver" | "executor"; - -// Check if array includes a specific element -type ArrayIncludes = T extends readonly [ - infer First, - ...infer Rest, -] - ? First extends U - ? true - : ArrayIncludes - : false; - -// Check if dependencies array includes a specific dependency -export type HasDependency< - Deps extends readonly DependencyKind[], - D extends DependencyKind, -> = ArrayIncludes; - -// ======================================== -// Source info type for TailorDB types -// Re-exported from parser module -// ======================================== - -export type { - UserDefinedTypeSource, - PluginGeneratedTypeSource, - TypeSourceInfoEntry, -} from "#/parser/service/tailordb/types"; - -// ======================================== -// Method interfaces for each dependency -// ======================================== - -export interface TailorDBProcessMethods { - processType(args: { - type: TailorDBType; - namespace: string; - source: TypeSourceInfoEntry; - /** Plugin attachments configured on this type via .plugin() method */ - plugins: readonly PluginAttachment[]; - }): T | Promise; - - processTailorDBNamespace?(args: { - namespace: string; - types: Record; - }): Ts | Promise; -} - -export interface ResolverProcessMethods { - processResolver(args: { resolver: Resolver; namespace: string }): R | Promise; - - processResolverNamespace?(args: { - namespace: string; - resolvers: Record; - }): Rs | Promise; -} - -export interface ExecutorProcessMethods { - processExecutor(executor: Executor): E | Promise; -} - -// ======================================== -// Conditional method selection -// ======================================== - -type SelectMethods = (HasDependency< - Deps, - "tailordb" -> extends true - ? TailorDBProcessMethods - : object) & - (HasDependency extends true ? ResolverProcessMethods : object) & - (HasDependency extends true ? ExecutorProcessMethods : object); - -// ======================================== -// Conditional input selection for aggregate -// ======================================== - -interface TailorDBInputPart { - tailordb: TailorDBNamespaceResult[]; -} - -interface ResolverInputPart { - resolver: ResolverNamespaceResult[]; -} - -interface ExecutorInputPart { - executor: E[]; -} - -// Auth is always available (resolved after TailorDB, before generators) -interface AuthPart { - auth?: GeneratorAuthInput; -} - -type SelectInput = (HasDependency< - Deps, - "tailordb" -> extends true - ? TailorDBInputPart - : object) & - (HasDependency extends true ? ResolverInputPart : object) & - (HasDependency extends true ? ExecutorInputPart : object) & - AuthPart; - -/** Input type for TailorDB-only generators */ -export type TailorDBInput = TailorDBInputPart & AuthPart; - -/** Input type for Resolver-only generators */ -export type ResolverInput = ResolverInputPart & AuthPart; - -/** Input type for Executor-only generators */ -export type ExecutorInput = ExecutorInputPart & AuthPart; - -/** Input type for full generators (TailorDB + Resolver + Executor) */ -export type FullInput = TailorDBInputPart & - ResolverInputPart & - ExecutorInputPart & - AuthPart; - -/** Arguments type for aggregate method */ -export interface AggregateArgs { - input: Input; - baseDir: string; - configPath: string; -} - -// ======================================== -// CodeGenerator type definition -// ======================================== - -interface CodeGeneratorCore { - readonly id: string; - readonly description: string; -} - -/** - * Generator interface with dependencies-based conditional methods. - * @template Deps - Dependencies array (e.g., ['tailordb'], ['tailordb', 'resolver']) - * @template T - Return type of processType - * @template R - Return type of processResolver - * @template E - Return type of processExecutor - * @template Ts - Return type of processTailorDBNamespace (default: Record) - * @template Rs - Return type of processResolverNamespace (default: Record) - */ -export type CodeGenerator< - Deps extends readonly DependencyKind[], - T = unknown, - R = unknown, - E = unknown, - Ts = Record, - Rs = Record, -> = CodeGeneratorCore & - SelectMethods & { - readonly dependencies: Deps; - - aggregate(args: { - input: SelectInput; - baseDir: string; - configPath: string; - }): GeneratorResult | Promise; - }; - -// ======================================== -// Helper types for common generator patterns -// ======================================== - -/** TailorDB-only generator */ -export type TailorDBGenerator> = CodeGenerator< - readonly ["tailordb"], - T, - never, - never, - Ts, - never ->; - -/** Resolver-only generator */ -export type ResolverGenerator> = CodeGenerator< - readonly ["resolver"], - never, - R, - never, - never, - Rs ->; - -/** Executor-only generator */ -export type ExecutorGenerator = CodeGenerator< - readonly ["executor"], - never, - never, - E, - never, - never ->; - -/** TailorDB + Resolver generator */ -export type TailorDBResolverGenerator< - T = unknown, - R = unknown, - Ts = Record, - Rs = Record, -> = CodeGenerator; - -/** Full generator (all dependencies) */ -export type FullCodeGenerator< - T = unknown, - R = unknown, - E = unknown, - Ts = Record, - Rs = Record, -> = CodeGenerator; - -// ======================================== -// Runtime utility -// ======================================== - -interface DependencyCarrier { - dependencies: readonly DependencyKind[]; -} - -/** - * Type guard to check if a generator has a specific dependency. - * @template D - * @param generator - Code generator instance - * @param dependency - Dependency kind to check - * @returns True if the generator has the dependency - */ -export function hasDependency( - generator: DependencyCarrier, - dependency: D, -): boolean { - return generator.dependencies.includes(dependency); -} - -// Type for any generator (used in GenerationManager) -// This is a more permissive type that includes all possible methods -export interface AnyCodeGenerator { - readonly id: string; - readonly description: string; - readonly dependencies: readonly DependencyKind[]; - - processType?(args: { - type: TailorDBType; - namespace: string; - source: TypeSourceInfoEntry; - plugins: readonly PluginAttachment[]; - }): unknown | Promise; - - processTailorDBNamespace?(args: { - namespace: string; - types: Record; - }): unknown | Promise; - - processResolver?(args: { resolver: Resolver; namespace: string }): unknown | Promise; - - processResolverNamespace?(args: { - namespace: string; - resolvers: Record; - }): unknown | Promise; - - processExecutor?(executor: Executor): unknown | Promise; - - aggregate(args: { - input: Record; - baseDir: string; - configPath: string; - }): GeneratorResult | Promise; -} diff --git a/packages/sdk/src/cli/commands/generate/watch/index.test.ts b/packages/sdk/src/cli/commands/generate/watch/index.test.ts deleted file mode 100644 index 1b0f61719..000000000 --- a/packages/sdk/src/cli/commands/generate/watch/index.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -/* eslint-disable import-x/order */ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "pathe"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { logger } from "#/cli/shared/logger"; -import type * as Chokidar from "chokidar"; - -const madgeMock = vi.hoisted(() => - vi.fn(async () => ({ - obj: () => ({}), - circular: () => [], - })), -); - -vi.mock("madge", () => ({ - default: madgeMock, -})); - -const chokidarWatchMock = vi.hoisted(() => vi.fn()); - -vi.mock("chokidar", async (importOriginal) => { - const actual = await importOriginal(); - chokidarWatchMock.mockImplementation(actual.watch); - return { ...actual, watch: chokidarWatchMock }; -}); - -import { - createDependencyGraphManager, - createDependencyWatcher, - type DependencyGraphManager, - type DependencyWatcher, - WatcherError, - WatcherErrorCode, -} from "./index"; - -let manager: DependencyGraphManager; - -beforeEach(() => { - madgeMock.mockReset(); - madgeMock.mockImplementation(async () => ({ - obj: () => ({}), - circular: () => [], - })); - chokidarWatchMock.mockClear(); - manager = createDependencyGraphManager(); -}); - -async function createTempDir(): Promise { - return await fs.mkdtemp(path.join(os.tmpdir(), "dependency-watcher-test-")); -} - -async function createTestFile(filePath: string, content: string): Promise { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, content); -} - -describe("DependencyWatcher", () => { - let tempDir: string; - let watcher: DependencyWatcher; - - beforeEach(async () => { - tempDir = await createTempDir(); - watcher = createDependencyWatcher({ - debounceTime: 10, - detectCircularDependencies: true, - }); - }); - - afterEach(async () => { - await watcher.stop(); - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - describe("initialization", () => { - test("can initialize correctly", async () => { - await watcher.initialize(); - const status = watcher.getWatchStatus(); - expect(status.isWatching).toBe(true); - expect(status.groupCount).toBe(0); - expect(status.fileCount).toBe(0); - }); - }); - - describe("watch group management", () => { - test("can add watch group", async () => { - const testFile = path.join(tempDir, "test.ts"); - await createTestFile(testFile, 'export const test = "hello";'); - - await watcher.addWatchGroup("test-group", [testFile], tempDir); - - const status = watcher.getWatchStatus(); - expect(status.groupCount).toBe(1); - expect(status.fileCount).toBe(1); - }); - - test("can watch multiple files with glob pattern", async () => { - const testFile1 = path.join(tempDir, "file1.ts"); - const testFile2 = path.join(tempDir, "file2.ts"); - await createTestFile(testFile1, 'export const file1 = "hello";'); - await createTestFile(testFile2, 'export const file2 = "world";'); - - const pattern = path.join(tempDir, "*.ts"); - await watcher.addWatchGroup("test-group", [pattern], tempDir); - - const status = watcher.getWatchStatus(); - expect(status.groupCount).toBe(1); - expect(status.fileCount).toBe(2); - }); - - test("can remove watch group", async () => { - const testFile = path.join(tempDir, "test.ts"); - await createTestFile(testFile, 'export const test = "hello";'); - - await watcher.addWatchGroup("test-group", [testFile], tempDir); - await watcher.removeWatchGroup("test-group"); - - const status = watcher.getWatchStatus(); - expect(status.groupCount).toBe(0); - expect(status.fileCount).toBe(0); - }); - - test("unwatches the resolved absolute files, not the original relative pattern", async () => { - const testFile = path.join(tempDir, "src", "test.ts"); - await createTestFile(testFile, 'export const test = "hello";'); - - await watcher.addWatchGroup("test-group", ["src/*.ts"], tempDir); - - const chokidarInstance = chokidarWatchMock.mock.results[0]?.value; - const unwatchSpy = vi.spyOn(chokidarInstance, "unwatch"); - - await watcher.removeWatchGroup("test-group"); - - expect(unwatchSpy).toHaveBeenCalledWith([path.resolve(testFile)]); - }); - - test("duplicate group ID causes error", async () => { - const testFile = path.join(tempDir, "test.ts"); - await createTestFile(testFile, 'export const test = "hello";'); - - await watcher.addWatchGroup("test-group", [testFile], tempDir); - - await expect(watcher.addWatchGroup("test-group", [testFile], tempDir)).rejects.toThrow( - WatcherError, - ); - }); - - test("resolves relative patterns against baseDir, not process.cwd()", async () => { - const testFile = path.join(tempDir, "src", "test.ts"); - await createTestFile(testFile, 'export const test = "hello";'); - - const unrelatedCwd = await createTempDir(); - const originalCwd = process.cwd(); - process.chdir(unrelatedCwd); - try { - await watcher.addWatchGroup("test-group", ["src/*.ts"], tempDir); - } finally { - process.chdir(originalCwd); - await fs.rm(unrelatedCwd, { recursive: true, force: true }); - } - - const status = watcher.getWatchStatus(); - expect(status.fileCount).toBe(1); - }); - - test("falls back to process.cwd() when baseDir matches nothing (legacy cwd-relative config)", async () => { - // baseDir has no "src" directory at all; the pattern only matches - // something under a different cwd, mirroring a config written before - // baseDir-relative resolution existed. - const legacyCwd = await createTempDir(); - const legacyFile = path.join(legacyCwd, "src", "legacy.ts"); - await createTestFile(legacyFile, 'export const legacy = "hello";'); - - const originalCwd = process.cwd(); - process.chdir(legacyCwd); - try { - await watcher.addWatchGroup("test-group", ["src/*.ts"], tempDir); - } finally { - process.chdir(originalCwd); - await fs.rm(legacyCwd, { recursive: true, force: true }); - } - - const status = watcher.getWatchStatus(); - expect(status.fileCount).toBe(1); - }); - - test("warns again for a different group falling back for the same baseDir", async () => { - const legacyCwd = await createTempDir(); - await createTestFile(path.join(legacyCwd, "src", "legacy.ts"), 'export const a = "hello";'); - await createTestFile(path.join(legacyCwd, "other", "legacy.ts"), 'export const b = "hello";'); - - using warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); - const originalCwd = process.cwd(); - process.chdir(legacyCwd); - try { - await watcher.addWatchGroup("group-a", ["src/*.ts"], tempDir); - await watcher.addWatchGroup("group-b", ["other/*.ts"], tempDir); - } finally { - process.chdir(originalCwd); - await fs.rm(legacyCwd, { recursive: true, force: true }); - } - - expect(warnSpy).toHaveBeenCalledTimes(2); - }); - }); - - describe("validation", () => { - test.each([ - ["invalid group ID", "", ["test.ts"]], - ["empty pattern array", "test-group", []], - ])("%s causes error", async (_, groupId, patterns) => { - await expect(watcher.addWatchGroup(groupId, patterns, tempDir)).rejects.toThrow(WatcherError); - }); - }); - - describe("impact scope calculation", () => { - test("impact scope is empty for files without dependencies", async () => { - const testFile = path.join(tempDir, "test.ts"); - await createTestFile(testFile, 'export const test = "hello";'); - - await watcher.addWatchGroup("test-group", [testFile], tempDir); - - const impact = watcher.calculateImpact(testFile); - expect(impact.changedFile).toBe(testFile); - expect(impact.affectedFiles).toEqual([testFile]); - expect(impact.affectedGroups).toEqual(["test-group"]); - }); - }); - - describe("error handling", () => { - test("can set error callback", () => { - const errorCallback = vi.fn(); - watcher.onError(errorCallback); - - expect(errorCallback).not.toHaveBeenCalled(); - }); - - test("WatcherError is created correctly", () => { - const error = new WatcherError( - "Test error", - WatcherErrorCode.INVALID_WATCH_GROUP, - "/test/file.ts", - ); - - expect(error.message).toBe("Test error"); - expect(error.code).toBe(WatcherErrorCode.INVALID_WATCH_GROUP); - expect(error.filePath).toBe("/test/file.ts"); - expect(error.name).toBe("WatcherError"); - }); - }); - - describe("watch status", () => { - test("can get watch status correctly", async () => { - const testFile1 = path.join(tempDir, "file1.ts"); - const testFile2 = path.join(tempDir, "file2.ts"); - await createTestFile(testFile1, 'export const file1 = "hello";'); - await createTestFile(testFile2, 'export const file2 = "world";'); - - await watcher.addWatchGroup("group1", [testFile1], tempDir); - await watcher.addWatchGroup("group2", [testFile2], tempDir); - - const status = watcher.getWatchStatus(); - expect(status.isWatching).toBe(true); - expect(status.groupCount).toBe(2); - expect(status.fileCount).toBe(2); - expect(status.dependencyNodeCount).toBeGreaterThanOrEqual(0); - }); - }); - - describe("circular dependency detection", () => { - test("can detect circular dependencies", async () => { - const circular = watcher.detectCircularDependencies(); - expect(Array.isArray(circular)).toBe(true); - }); - }); -}); - -describe("DependencyGraphManager", () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await createTempDir(); - }); - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - test("can build graph with empty file array", async () => { - await expect(manager.buildGraph([])).resolves.not.toThrow(); - }); - - test("calls madge to build dependency graph", async () => { - const testFile = path.join(tempDir, "sample.ts"); - await createTestFile(testFile, "export const value = 1;"); - - await manager.buildGraph([testFile]); - - expect(madgeMock).toHaveBeenCalledWith( - [testFile], - expect.objectContaining({ - fileExtensions: expect.arrayContaining(["ts", "js"]), - }), - ); - }); - - test("detects error when madge does not provide function", async () => { - const mockedMadge = await import("madge"); - const originalDefault = (mockedMadge as { default?: unknown }).default; - (mockedMadge as { default?: unknown }).default = undefined; - - const localManager = createDependencyGraphManager(); - - try { - await expect( - localManager.buildGraph([path.join(tempDir, "broken.ts")]), - ).rejects.toMatchObject({ - code: WatcherErrorCode.MADGE_INITIALIZATION_FAILED, - }); - } finally { - (mockedMadge as { default?: unknown }).default = originalDefault; - } - }); -}); - -describe("WatcherErrorCode", () => { - test("all error codes are defined", () => { - expect(WatcherErrorCode.DEPENDENCY_ANALYSIS_FAILED).toBe("DEPENDENCY_ANALYSIS_FAILED"); - expect(WatcherErrorCode.FILE_WATCH_FAILED).toBe("FILE_WATCH_FAILED"); - expect(WatcherErrorCode.CIRCULAR_DEPENDENCY_DETECTED).toBe("CIRCULAR_DEPENDENCY_DETECTED"); - expect(WatcherErrorCode.INVALID_WATCH_GROUP).toBe("INVALID_WATCH_GROUP"); - expect(WatcherErrorCode.MADGE_INITIALIZATION_FAILED).toBe("MADGE_INITIALIZATION_FAILED"); - }); -}); diff --git a/packages/sdk/src/cli/commands/generate/watch/index.ts b/packages/sdk/src/cli/commands/generate/watch/index.ts deleted file mode 100644 index e818434c2..000000000 --- a/packages/sdk/src/cli/commands/generate/watch/index.ts +++ /dev/null @@ -1,753 +0,0 @@ -import { glob } from "node:fs/promises"; -import { watch } from "chokidar"; -import * as madgeModule from "madge"; -import * as path from "pathe"; -import { logger, styles } from "#/cli/shared/logger"; -import type { MadgeLoader } from "./types"; - -/** - * Types of file change events. - */ -type FileChangeEvent = "add" | "change" | "unlink"; - -/** - * Definition of a watch group. - */ -interface WatchGroup { - /** Unique identifier of the group. */ - id: string; - /** File patterns to watch (glob format). */ - patterns: string[]; - /** List of absolute file paths in the group. */ - files: Set; -} - -/** - * Node in the dependency graph. - */ -interface DependencyNode { - /** Absolute path of the file. */ - filePath: string; - /** List of files this file depends on. */ - dependencies: Set; - /** List of files that depend on this file. */ - dependents: Set; -} - -/** - * Impact analysis result. - */ -interface ImpactAnalysisResult { - /** Changed file. */ - changedFile: string; - /** List of affected files (all files depending on the changed file). */ - affectedFiles: string[]; - /** List of affected watch groups. */ - affectedGroups: string[]; -} - -/** - * Type of the error handling callback. - */ -type ErrorCallback = (error: WatcherError) => void; - -/** - * Options for the watcher system. - */ -interface WatcherOptions { - /** Options for chokidar. */ - chokidarOptions?: Parameters[1]; - /** Options for madge. */ - madgeOptions?: Parameters[1]; - /** Update interval for the dependency graph (milliseconds). */ - dependencyUpdateInterval?: number; - /** Debounce duration (milliseconds). */ - debounceTime?: number; - /** Whether to enable circular dependency detection. */ - detectCircularDependencies?: boolean; -} - -/** - * Watcher status. - */ -interface WatchStatus { - /** Whether watching is active. */ - isWatching: boolean; - /** Number of watch groups. */ - groupCount: number; - /** Number of watched files. */ - fileCount: number; - /** Number of nodes in the dependency graph. */ - dependencyNodeCount: number; -} - -/** - * Graph statistics. - */ -interface GraphStats { - /** Number of nodes. */ - nodeCount: number; - /** Number of edges. */ - edgeCount: number; - /** Number of circular dependencies. */ - circularDependencyCount: number; -} - -// addWatchGroup is called once per watch group, so a single `generate --watch` -// run can call it multiple times for the same baseDir (once per -// resolver/executor/tailordb/etc. config sharing that directory). Track which -// (baseDir, groupId) pairs have already warned so each one only warns once per -// run — keyed by groupId too, since different groups sharing a baseDir each -// need their own warning. -const warnedFallbacks = new Set(); - -/** - * Error codes. - */ -const WatcherErrorCode = { - DEPENDENCY_ANALYSIS_FAILED: "DEPENDENCY_ANALYSIS_FAILED", - FILE_WATCH_FAILED: "FILE_WATCH_FAILED", - CIRCULAR_DEPENDENCY_DETECTED: "CIRCULAR_DEPENDENCY_DETECTED", - INVALID_WATCH_GROUP: "INVALID_WATCH_GROUP", - MADGE_INITIALIZATION_FAILED: "MADGE_INITIALIZATION_FAILED", -} as const; -type WatcherErrorCode = (typeof WatcherErrorCode)[keyof typeof WatcherErrorCode]; - -/** - * Watcher-specific error. - */ -export class WatcherError extends Error { - constructor( - message: string, - public readonly code: WatcherErrorCode, - public readonly filePath?: string, - public readonly originalError?: Error, - ) { - super(message); - this.name = "WatcherError"; - } -} - -/** - * Dependency graph manager type. - */ -export type DependencyGraphManager = { - buildGraph: (filePaths: string[]) => Promise; - getDependents: (filePath: string) => string[]; - getDependencies: (filePath: string) => string[]; - findCircularDependencies: () => string[][]; - addNode: (filePath: string) => void; - removeNode: (filePath: string) => void; - getGraphStats: () => GraphStats; -}; - -/** - * Creates a dependency graph manager. - * @param options - Options for madge - * @returns DependencyGraphManager instance - */ -export function createDependencyGraphManager( - options: Parameters[1] = {}, -): DependencyGraphManager { - const graph: Map = new Map(); - let madgeInstance: Awaited> | null = null; - let madgeLoader: MadgeLoader | null = null; - - function getMadgeLoader(): MadgeLoader { - if (madgeLoader) { - return madgeLoader; - } - - const defaultExport = (madgeModule as { default?: unknown }).default; - if (typeof defaultExport === "function") { - madgeLoader = defaultExport as MadgeLoader; - return madgeLoader; - } - - if (typeof (madgeModule as unknown) === "function") { - madgeLoader = madgeModule as unknown as MadgeLoader; - return madgeLoader; - } - - throw new WatcherError( - "Failed to initialize madge analyzer: module did not export a callable function.", - WatcherErrorCode.MADGE_INITIALIZATION_FAILED, - ); - } - - function traverseDependents(filePath: string, visited: Set): string[] { - if (visited.has(filePath)) return []; - visited.add(filePath); - - const node = graph.get(filePath); - if (!node) return []; - - const result: string[] = []; - for (const dependent of node.dependents) { - result.push(dependent); - result.push(...traverseDependents(dependent, visited)); - } - - return result; - } - - function traverseDependencies(filePath: string, visited: Set): string[] { - if (visited.has(filePath)) return []; - visited.add(filePath); - - const node = graph.get(filePath); - if (!node) return []; - - const result: string[] = []; - for (const dependency of node.dependencies) { - result.push(dependency); - result.push(...traverseDependencies(dependency, visited)); - } - - return result; - } - - function addNode(filePath: string): void { - const absolutePath = path.resolve(filePath); - if (!graph.has(absolutePath)) { - graph.set(absolutePath, { - filePath: absolutePath, - dependencies: new Set(), - dependents: new Set(), - }); - } - } - - function removeNode(filePath: string): void { - const absolutePath = path.resolve(filePath); - const node = graph.get(absolutePath); - if (!node) return; - - for (const dep of node.dependencies) { - const depNode = graph.get(dep); - if (depNode) { - depNode.dependents.delete(absolutePath); - } - } - - for (const dependent of node.dependents) { - const dependentNode = graph.get(dependent); - if (dependentNode) { - dependentNode.dependencies.delete(absolutePath); - } - } - - graph.delete(absolutePath); - } - - function findCircularDependencies(): string[][] { - if (!madgeInstance) return []; - try { - return madgeInstance.circular(); - } catch (error) { - logger.warn(`Failed to detect circular dependencies: ${String(error)}`); - return []; - } - } - - return { - async buildGraph(filePaths: string[]): Promise { - try { - if (filePaths.length === 0) return; - - const madge = getMadgeLoader(); - - madgeInstance = await madge(filePaths, { - fileExtensions: ["ts", "js"], - excludeRegExp: [/node_modules/], - baseDir: ".", - ...options, - }); - - const dependencyObj = madgeInstance.obj() as Record; - graph.clear(); - - for (const filePath of filePaths) { - addNode(filePath); - } - - for (const [filePath, dependencies] of Object.entries(dependencyObj)) { - const absoluteFilePath = path.resolve(".", filePath); - const node = graph.get(absoluteFilePath); - if (!node) continue; - - for (const dep of dependencies) { - const absoluteDepPath = path.resolve(".", dep); - node.dependencies.add(absoluteDepPath); - - const depNode = graph.get(absoluteDepPath); - if (depNode) { - depNode.dependents.add(absoluteFilePath); - } - } - } - } catch (error) { - if (error instanceof WatcherError) { - throw error; - } - throw new WatcherError( - `Failed to build dependency graph: ${error instanceof Error ? error.message : String(error)}`, - WatcherErrorCode.DEPENDENCY_ANALYSIS_FAILED, - undefined, - error instanceof Error ? error : undefined, - ); - } - }, - - getDependents(filePath: string): string[] { - const visited = new Set(); - return traverseDependents(path.resolve(filePath), visited); - }, - - getDependencies(filePath: string): string[] { - const visited = new Set(); - return traverseDependencies(path.resolve(filePath), visited); - }, - - findCircularDependencies, - addNode, - removeNode, - - getGraphStats(): GraphStats { - let edgeCount = 0; - for (const node of graph.values()) { - edgeCount += node.dependencies.size; - } - - return { - nodeCount: graph.size, - edgeCount, - circularDependencyCount: findCircularDependencies().length, - }; - }, - }; -} - -/** - * Dependency watcher type. - */ -export type DependencyWatcher = { - initialize: () => Promise; - addWatchGroup: (groupId: string, patterns: string[], baseDir: string) => Promise; - removeWatchGroup: (groupId: string) => Promise; - start: () => Promise; - stop: () => Promise; - onError: (callback: ErrorCallback) => void; - updateDependencyGraph: () => Promise; - calculateImpact: (filePath: string) => ImpactAnalysisResult; - detectCircularDependencies: () => string[][]; - getWatchStatus: () => WatchStatus; - setRestartCallback: (callback: () => void) => void; -}; - -/** - * Resolve a watch group's glob patterns against baseDir into absolute file paths. - * @param groupId - Watch group identifier, used only for the progress log line - * @param patterns - Glob patterns to resolve - * @param baseDir - Directory relative patterns are resolved against - * @returns Absolute paths of every matched file - */ -async function globPatterns( - groupId: string, - patterns: string[], - baseDir: string, -): Promise> { - const files = new Set(); - for (const pattern of patterns) { - const absolutePattern = path.resolve(baseDir, pattern); - logger.log( - `${styles.dim(`Watch pattern for`)} ${styles.dim(groupId + ":")} ${path.relative(process.cwd(), absolutePattern)}`, - ); - for await (const file of glob(absolutePattern)) { - files.add(path.resolve(file)); - } - } - return files; -} - -/** - * Creates a dependency watcher. - * @param options - Watcher options - * @returns DependencyWatcher instance - */ -export function createDependencyWatcher(options: WatcherOptions = {}): DependencyWatcher { - let chokidarWatcher: ReturnType | null = null; - const watchGroups: Map = new Map(); - const dependencyGraphManager = createDependencyGraphManager(options.madgeOptions); - let errorCallback: ErrorCallback | null = null; - const debounceTimers: Map = new Map(); - let isInitialized = false; - const dependencyCache: Map = new Map(); - const maxCacheSize = 1000; - let signalHandlersRegistered = false; - let restartCallback: (() => void) | null = null; - - function validateWatchGroup(groupId: string, patterns: string[]): void { - if (!groupId || typeof groupId !== "string") { - throw new WatcherError( - "Group ID must be a non-empty string", - WatcherErrorCode.INVALID_WATCH_GROUP, - ); - } - - if (!Array.isArray(patterns) || patterns.length === 0) { - throw new WatcherError( - "Patterns must be a non-empty array", - WatcherErrorCode.INVALID_WATCH_GROUP, - ); - } - - if (watchGroups.has(groupId)) { - throw new WatcherError( - `Watch group with ID '${groupId}' already exists`, - WatcherErrorCode.INVALID_WATCH_GROUP, - ); - } - } - - function handleError(error: WatcherError): void { - logger.error( - `[DependencyWatcher] ${error.message} (code: ${error.code}, filePath: ${error.filePath})`, - ); - - if (errorCallback) { - errorCallback(error); - } - } - - function setCacheValue(key: string, value: string[]): void { - if (dependencyCache.size >= maxCacheSize) { - const firstKey = dependencyCache.keys().next().value; - if (firstKey) { - dependencyCache.delete(firstKey); - } - } - dependencyCache.set(key, value); - } - - function findAffectedFiles(changedFile: string): string[] { - return dependencyGraphManager.getDependents(changedFile); - } - - function findAffectedGroups(affectedFiles: string[]): string[] { - logger.debug(`Finding affected groups for files: ${affectedFiles.join(", ")}`); - const affectedGroupsSet = new Set(); - - for (const [groupId, group] of watchGroups) { - for (const affectedFile of affectedFiles) { - if (group.files.has(affectedFile)) { - logger.debug(`Group ${groupId} is affected by file: ${affectedFile}`); - affectedGroupsSet.add(groupId); - break; - } - } - } - - return Array.from(affectedGroupsSet); - } - - function calculateImpact(filePath: string): ImpactAnalysisResult { - const cacheKey = `impact:${filePath}`; - let affectedFiles = dependencyCache.get(cacheKey); - - if (!affectedFiles) { - affectedFiles = findAffectedFiles(filePath); - setCacheValue(cacheKey, affectedFiles); - } - - // Include the changed file itself in the affected files - const allAffectedFiles = [filePath, ...affectedFiles]; - const affectedGroups = findAffectedGroups(allAffectedFiles); - - return { - changedFile: filePath, - affectedFiles: allAffectedFiles, - affectedGroups, - }; - } - - async function updateDependencyGraph(): Promise { - const allFiles: string[] = []; - for (const group of watchGroups.values()) { - allFiles.push(...Array.from(group.files)); - } - - await dependencyGraphManager.buildGraph(allFiles); - dependencyCache.clear(); - - if (options.detectCircularDependencies) { - const circularDeps = dependencyGraphManager.findCircularDependencies(); - if (circularDeps.length > 0) { - logger.warn(`Circular dependencies detected: ${JSON.stringify(circularDeps)}`); - } - } - } - - async function handleFileChange(event: FileChangeEvent, filePath: string): Promise { - try { - const absolutePath = path.resolve(filePath); - - if (event === "unlink") { - dependencyGraphManager.removeNode(absolutePath); - } else { - dependencyGraphManager.addNode(absolutePath); - if (event === "change") { - await updateDependencyGraph(); - } - } - - dependencyCache.clear(); - - const impactResult = calculateImpact(absolutePath); - - // If any groups are affected, trigger restart instead of calling callbacks - if (impactResult.affectedGroups.length > 0) { - logger.info("File change detected, restarting watch process...", { - mode: "stream", - }); - logger.info(`Changed file: ${absolutePath}`, { mode: "stream" }); - logger.info(`Affected groups: ${impactResult.affectedGroups.join(", ")}`, { - mode: "stream", - }); - - if (restartCallback) { - restartCallback(); - } - } else { - logger.debug(`No affected groups found for file: ${absolutePath}`); - } - } catch (error) { - handleError( - new WatcherError( - `Failed to handle file change: ${error instanceof Error ? error.message : String(error)}`, - WatcherErrorCode.DEPENDENCY_ANALYSIS_FAILED, - filePath, - error instanceof Error ? error : undefined, - ), - ); - } - } - - function debounceFileChange(event: FileChangeEvent, filePath: string): void { - const key = `${event}:${filePath}`; - - if (debounceTimers.has(key)) { - clearTimeout(debounceTimers.get(key)); - } - - const timer = setTimeout(() => { - handleFileChange(event, filePath); - debounceTimers.delete(key); - }, options.debounceTime || 100); - - debounceTimers.set(key, timer); - } - - async function stop(): Promise { - if (chokidarWatcher) { - await chokidarWatcher.close(); - chokidarWatcher = null; - } - - for (const timer of debounceTimers.values()) { - clearTimeout(timer); - } - debounceTimers.clear(); - - removeSignalHandlers(); - isInitialized = false; - } - - function setupSignalHandlers(): void { - if (signalHandlersRegistered) return; - - const handleSignal = async () => { - try { - await stop(); - logger.info("Watcher stopped successfully"); - process.exit(0); - } catch (error) { - logger.error(`Error during shutdown: ${String(error)}`); - process.exit(0); - } - }; - - process.on("SIGINT", () => handleSignal()); - process.on("SIGTERM", () => handleSignal()); - signalHandlersRegistered = true; - } - - function removeSignalHandlers(): void { - if (!signalHandlersRegistered) return; - - process.removeAllListeners("SIGINT"); - process.removeAllListeners("SIGTERM"); - signalHandlersRegistered = false; - } - - async function initialize(): Promise { - if (isInitialized) return; - - try { - chokidarWatcher = watch([], { - ignored: /node_modules/, - persistent: true, - ignoreInitial: true, - usePolling: false, - awaitWriteFinish: { - stabilityThreshold: 100, - pollInterval: 100, - }, - ...options.chokidarOptions, - }); - - chokidarWatcher.on("add", (filePath: string) => { - logger.debug(`File added: ${filePath}`); - debounceFileChange("add", filePath); - }); - - chokidarWatcher.on("change", (filePath: string) => { - logger.debug(`File changed: ${filePath}`); - debounceFileChange("change", filePath); - }); - - chokidarWatcher.on("unlink", (filePath: string) => { - logger.debug(`File removed: ${filePath}`); - debounceFileChange("unlink", filePath); - }); - - chokidarWatcher.on("error", (error: unknown) => { - logger.error(`Watcher error: ${error instanceof Error ? error.message : String(error)}`, { - mode: "stream", - }); - handleError( - new WatcherError( - `File watcher error: ${error instanceof Error ? error.message : String(error)}`, - WatcherErrorCode.FILE_WATCH_FAILED, - undefined, - error instanceof Error ? error : undefined, - ), - ); - }); - - setupSignalHandlers(); - isInitialized = true; - } catch (error) { - throw new WatcherError( - `Failed to initialize watcher: ${error instanceof Error ? error.message : String(error)}`, - WatcherErrorCode.FILE_WATCH_FAILED, - undefined, - error instanceof Error ? error : undefined, - ); - } - } - - return { - initialize, - - async addWatchGroup(groupId: string, patterns: string[], baseDir: string): Promise { - validateWatchGroup(groupId, patterns); - - if (!isInitialized) { - await initialize(); - } - - let files = await globPatterns(groupId, patterns, baseDir); - - if (files.size === 0 && baseDir !== process.cwd()) { - // v1 compatibility fallback: pre-existing configs may have relative - // patterns written against the invocation cwd rather than baseDir. - // Remove this fallback in v2, once such configs are expected to have - // migrated. - const fallbackKey = `${baseDir}\0${groupId}`; - if (!warnedFallbacks.has(fallbackKey)) { - warnedFallbacks.add(fallbackKey); - logger.warn( - `No files matched watch patterns for "${groupId}" relative to "${baseDir}"; ` + - `falling back to process.cwd(). Update this config's file patterns to be ` + - `relative to its own directory before v2, when this fallback will be removed.`, - ); - } - files = await globPatterns(groupId, patterns, process.cwd()); - } - - const watchGroup: WatchGroup = { - id: groupId, - patterns, - files, - }; - - watchGroups.set(groupId, watchGroup); - - if (chokidarWatcher) { - const filePaths = Array.from(files); - chokidarWatcher.add(filePaths); - } - - await updateDependencyGraph(); - }, - - async removeWatchGroup(groupId: string): Promise { - const watchGroup = watchGroups.get(groupId); - if (!watchGroup) return; - - if (chokidarWatcher) { - chokidarWatcher.unwatch(Array.from(watchGroup.files)); - } - - for (const filePath of watchGroup.files) { - dependencyGraphManager.removeNode(filePath); - } - - watchGroups.delete(groupId); - dependencyCache.clear(); - }, - - async start(): Promise { - if (!isInitialized) { - await initialize(); - } - await updateDependencyGraph(); - }, - - stop, - - onError(callback: ErrorCallback): void { - errorCallback = callback; - }, - - updateDependencyGraph, - calculateImpact, - - detectCircularDependencies(): string[][] { - return dependencyGraphManager.findCircularDependencies(); - }, - - getWatchStatus(): WatchStatus { - let fileCount = 0; - for (const group of watchGroups.values()) { - fileCount += group.files.size; - } - - const stats = dependencyGraphManager.getGraphStats(); - - return { - isWatching: isInitialized && chokidarWatcher !== null, - groupCount: watchGroups.size, - fileCount, - dependencyNodeCount: stats.nodeCount, - }; - }, - - setRestartCallback(callback: () => void): void { - restartCallback = callback; - }, - }; -} - -export { WatcherErrorCode }; diff --git a/packages/sdk/src/cli/commands/generate/watch/types.ts b/packages/sdk/src/cli/commands/generate/watch/types.ts deleted file mode 100644 index 0213937b3..000000000 --- a/packages/sdk/src/cli/commands/generate/watch/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type * as madgeTypes from "madge"; - -export type MadgeLoader = ( - path: madgeTypes.MadgePath, - config?: madgeTypes.MadgeConfig, -) => Promise; diff --git a/packages/sdk/src/cli/commands/init.ts b/packages/sdk/src/cli/commands/init.ts index b5c8edd68..ac278d535 100644 --- a/packages/sdk/src/cli/commands/init.ts +++ b/packages/sdk/src/cli/commands/init.ts @@ -17,18 +17,16 @@ const detectPackageManager = () => { export const initCommand = defineAppCommand({ name: "init", description: "Initialize a new project using create-sdk.", - args: z - .object({ - name: arg(z.string().optional(), { - positional: true, - description: "Project name", - }), - template: arg(z.string().optional(), { - alias: "t", - description: "Template name", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().optional(), { + positional: true, + description: "Project name", + }), + template: arg(z.string().optional(), { + alias: "t", + description: "Template name", + }), + }), run: async (args) => { const packageJson = await readPackageJson(); const version = diff --git a/packages/sdk/src/cli/commands/login.test.ts b/packages/sdk/src/cli/commands/login.test.ts index c161191b3..22fe9d54f 100644 --- a/packages/sdk/src/cli/commands/login.test.ts +++ b/packages/sdk/src/cli/commands/login.test.ts @@ -11,7 +11,7 @@ import { readPlatformConfig, writePlatformConfig } from "#/cli/shared/context"; import { resetKeyringState } from "#/cli/shared/token-store"; import { loginCommand } from "./login"; -const xdgTempDir = vi.hoisted(() => `/tmp/tailor-login-${Date.now()}-${Math.random()}`); +const xdgTempDir = vi.hoisted(() => `/tmp/sdk-login-test-${Date.now()}-${Math.random()}`); const openMock = vi.hoisted(() => vi.fn()); const getAuthorizeUriMock = vi.hoisted(() => vi.fn()); const getTokenFromCodeRedirectMock = vi.hoisted(() => vi.fn()); @@ -114,7 +114,8 @@ describe("login --profile", () => { expect(pfConfig.profiles.dev?.user).toBe("u@example.com"); expect(pfConfig.current_user).toBeNull(); expect(pfConfig.users["https://api.dev.tailor.tech|machine-client"]).toMatchObject({ - access_token: "dev-token", + storage: "keyring", + token_expires_at: "2099-01-01T00:00:00.000Z", }); expect(closeConnectionPool).toHaveBeenCalledTimes(1); }); @@ -126,7 +127,10 @@ describe("login --profile", () => { refreshToken: "browser-refresh-token", expiresAt: Date.parse("2099-01-01T00:00:00.000Z"), }); - vi.mocked(fetchUserInfo).mockResolvedValue({ email: "browser@example.com" }); + vi.mocked(fetchUserInfo).mockResolvedValue({ + sub: "browser@example.com", + email: "browser@example.com", + }); openMock.mockImplementation(async () => { await fetch("http://localhost:8085/callback?code=browser-code&state=browser-state"); }); @@ -165,8 +169,8 @@ describe("login --profile", () => { expect(pfConfig.profiles.dev?.user).toBe("u@example.com"); expect(pfConfig.current_user).toBeNull(); expect(pfConfig.users["https://api.dev.tailor.tech|browser@example.com"]).toMatchObject({ - access_token: "browser-token", - refresh_token: "browser-refresh-token", + storage: "keyring", + token_expires_at: "2099-01-01T00:00:00.000Z", }); expect(closeConnectionPool).toHaveBeenCalledTimes(1); }); @@ -379,7 +383,8 @@ describe("login --profile", () => { access_token: "default-token", }); expect(pfConfig.users["https://api.dev.tailor.tech|machine-client"]).toMatchObject({ - access_token: "dev-token", + storage: "keyring", + token_expires_at: "2099-01-01T00:00:00.000Z", }); }); }); diff --git a/packages/sdk/src/cli/commands/login.ts b/packages/sdk/src/cli/commands/login.ts index 17d9454d7..e26e806fd 100644 --- a/packages/sdk/src/cli/commands/login.ts +++ b/packages/sdk/src/cli/commands/login.ts @@ -16,6 +16,7 @@ import { defineAppCommand } from "#/cli/shared/command"; import { platformConfigFromProfile, readPlatformConfig, + removeLegacyUserAlias, saveUserTokens, writePlatformConfig, } from "#/cli/shared/context"; @@ -47,8 +48,14 @@ function randomState() { function getProfileUserMismatch( args: ProfileLoginOptions, authenticatedUser: string, + authenticatedSubject?: string, ): ProfileUserMismatch | undefined { - if (!args.profile || !args.profileUser || authenticatedUser === args.profileUser) { + if ( + !args.profile || + !args.profileUser || + authenticatedUser === args.profileUser || + authenticatedSubject === args.profileUser + ) { return undefined; } return { @@ -136,7 +143,7 @@ const startAuthServer = async (args: ProfileLoginOptions = {}) => { const pfConfig = await readPlatformConfig(); await saveUserTokens( pfConfig, - userInfo.email, + userInfo.sub, { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ?? undefined, @@ -144,16 +151,17 @@ const startAuthServer = async (args: ProfileLoginOptions = {}) => { new Date( assertDefined(tokens.expiresAt, "token response missing expiresAt"), ).toISOString(), - args.platformConfig, + { platformConfig: args.platformConfig, email: userInfo.email }, ); - const mismatch = getProfileUserMismatch(args, userInfo.email); + const mismatch = getProfileUserMismatch(args, userInfo.email, userInfo.sub); if (mismatch) { writePlatformConfig(pfConfig); throw profileUserMismatchError(mismatch); } if (args.updateCurrentUser ?? true) { - pfConfig.current_user = userInfo.email; + pfConfig.current_user = userInfo.sub; } + await removeLegacyUserAlias(pfConfig, userInfo.email, userInfo.sub, args.platformConfig); writePlatformConfig(pfConfig); res.writeHead(200, { "Content-Type": "application/json" }); @@ -223,7 +231,7 @@ async function loginAsMachineUser( args.clientId, { accessToken: tokens.accessToken }, new Date(assertDefined(tokens.expiresAt, "token response missing expiresAt")).toISOString(), - args.platformConfig, + { platformConfig: args.platformConfig }, ); const mismatch = getProfileUserMismatch(args, args.clientId); if (mismatch) { @@ -241,19 +249,17 @@ export const loginCommand = defineAppCommand({ description: "Login to Tailor Platform.", args: z.xor([ z - .object({ + .strictObject({ profile: arg(z.string().optional(), { alias: "p", description: "Workspace profile whose platform settings should be used for login.", env: "TAILOR_PLATFORM_PROFILE", }), }) - .strict() .describe("User Login"), z - .object({ + .strictObject({ "machine-user": arg(z.literal(true), { - hiddenAlias: "machineuser", description: "Login as a platform machine user.", required: true, }), @@ -272,7 +278,6 @@ export const loginCommand = defineAppCommand({ env: "TAILOR_PLATFORM_PROFILE", }), }) - .strict() .describe("Machine User Login"), ]), run: async (args) => { diff --git a/packages/sdk/src/cli/commands/logout.test.ts b/packages/sdk/src/cli/commands/logout.test.ts index 82df89563..e5b19ba88 100644 --- a/packages/sdk/src/cli/commands/logout.test.ts +++ b/packages/sdk/src/cli/commands/logout.test.ts @@ -15,6 +15,7 @@ import { logoutCommand } from "./logout"; const xdgTempDir = vi.hoisted(() => `/tmp/tailor-logout-${Date.now()}-${Math.random()}`); const revokeMock = vi.hoisted(() => vi.fn()); +const keyringPasswords = vi.hoisted(() => new Map()); vi.mock("xdg-basedir", () => ({ xdgConfig: xdgTempDir, @@ -22,11 +23,19 @@ vi.mock("xdg-basedir", () => ({ vi.mock("@napi-rs/keyring", () => ({ Entry: class { - setPassword() {} + private key: string; + constructor(service: string, account: string) { + this.key = `${service}:${account}`; + } + setPassword(password: string) { + keyringPasswords.set(this.key, password); + } getPassword(): string | null { - return null; + return keyringPasswords.get(this.key) ?? null; + } + deletePassword() { + keyringPasswords.delete(this.key); } - deletePassword() {} }, })); @@ -52,6 +61,7 @@ describe("logout --profile", () => { beforeEach(async () => { vi.clearAllMocks(); resetKeyringState(); + keyringPasswords.clear(); writePlatformConfig({ version: 2, min_sdk_version: "1.29.0", @@ -84,7 +94,12 @@ describe("logout --profile", () => { refreshToken: "dev-refresh-token", }, futureDate, - { platformUrl: "https://api.dev.tailor.tech", oauth2ClientId: "dev-client" }, + { + platformConfig: { + platformUrl: "https://api.dev.tailor.tech", + oauth2ClientId: "dev-client", + }, + }, ); config.current_user = "u@example.com"; writePlatformConfig(config); diff --git a/packages/sdk/src/cli/commands/logout.ts b/packages/sdk/src/cli/commands/logout.ts index 4be7acce9..55f12107b 100644 --- a/packages/sdk/src/cli/commands/logout.ts +++ b/packages/sdk/src/cli/commands/logout.ts @@ -16,15 +16,13 @@ import { logger } from "#/cli/shared/logger"; export const logoutCommand = defineAppCommand({ name: "logout", description: "Logout from Tailor Platform.", - args: z - .object({ - profile: arg(z.string().optional(), { - alias: "p", - description: "Workspace profile whose platform settings should be used for logout.", - env: "TAILOR_PLATFORM_PROFILE", - }), - }) - .strict(), + args: z.strictObject({ + profile: arg(z.string().optional(), { + alias: "p", + description: "Workspace profile whose platform settings should be used for logout.", + env: "TAILOR_PLATFORM_PROFILE", + }), + }), run: async (args) => { const profile = args.profile || process.env.TAILOR_PLATFORM_PROFILE; const pfConfig = await readPlatformConfig(); diff --git a/packages/sdk/src/cli/commands/machineuser/list.ts b/packages/sdk/src/cli/commands/machineuser/list.ts index d1a4373be..df1ac09d5 100644 --- a/packages/sdk/src/cli/commands/machineuser/list.ts +++ b/packages/sdk/src/cli/commands/machineuser/list.ts @@ -93,12 +93,10 @@ export async function listMachineUsers( export const listCommand = defineAppCommand({ name: "list", description: "List all machine users in the application.", - args: z - .object({ - ...deploymentArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...paginationArgs(), + }), run: async (args) => { // Execute machineuser list logic const machineUsers = await listMachineUsers({ diff --git a/packages/sdk/src/cli/commands/machineuser/token.ts b/packages/sdk/src/cli/commands/machineuser/token.ts index 32a25bbb1..e198d2910 100644 --- a/packages/sdk/src/cli/commands/machineuser/token.ts +++ b/packages/sdk/src/cli/commands/machineuser/token.ts @@ -39,7 +39,7 @@ async function getMachineUserTokenInternal( }); if (!name) { throw new Error( - "Machine user is required. Provide the NAME positional argument, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor-sdk profile update --machine-user '.", + "Machine user is required. Provide the NAME positional argument, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor profile update --machine-user '.", ); } @@ -102,16 +102,14 @@ export async function getMachineUserToken( export const tokenCommand = defineAppCommand({ name: "token", description: "Get an access token for a machine user.", - args: z - .object({ - ...deploymentArgs, - name: arg(z.string().optional(), { - positional: true, - description: - "Machine user name. Falls back to TAILOR_PLATFORM_MACHINE_USER_NAME, then the active profile's default machine user.", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + name: arg(z.string().optional(), { + positional: true, + description: + "Machine user name. Falls back to TAILOR_PLATFORM_MACHINE_USER_NAME, then the active profile's default machine user.", + }), + }), run: async (args) => { // Execute machineuser token logic const token = await getMachineUserTokenInternal({ diff --git a/packages/sdk/src/cli/commands/oauth2client/get.ts b/packages/sdk/src/cli/commands/oauth2client/get.ts index 013bbb56c..e53e63aec 100644 --- a/packages/sdk/src/cli/commands/oauth2client/get.ts +++ b/packages/sdk/src/cli/commands/oauth2client/get.ts @@ -64,15 +64,13 @@ export async function getOAuth2Client( export const getCommand = defineAppCommand({ name: "get", description: "Get OAuth2 client credentials (including client secret).", - args: z - .object({ - ...deploymentArgs, - name: arg(z.string(), { - positional: true, - description: "OAuth2 client name", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + name: arg(z.string(), { + positional: true, + description: "OAuth2 client name", + }), + }), run: async (args) => { const credentials = await getOAuth2Client({ name: args.name, diff --git a/packages/sdk/src/cli/commands/oauth2client/list.ts b/packages/sdk/src/cli/commands/oauth2client/list.ts index 3cab7deb9..a78e65dac 100644 --- a/packages/sdk/src/cli/commands/oauth2client/list.ts +++ b/packages/sdk/src/cli/commands/oauth2client/list.ts @@ -62,12 +62,10 @@ export async function listOAuth2Clients( export const listCommand = defineAppCommand({ name: "list", description: "List all OAuth2 clients in the application.", - args: z - .object({ - ...deploymentArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...paginationArgs(), + }), run: async (args) => { const oauth2Clients = await listOAuth2Clients({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/open.ts b/packages/sdk/src/cli/commands/open.ts index 5ac933b1f..bc8c921e5 100644 --- a/packages/sdk/src/cli/commands/open.ts +++ b/packages/sdk/src/cli/commands/open.ts @@ -9,11 +9,9 @@ import { logger } from "#/cli/shared/logger"; export const openCommand = defineAppCommand({ name: "open", description: "Open Tailor Platform Console.", - args: z - .object({ - ...deploymentArgs, - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + }), run: async (args) => { const workspaceId = await loadWorkspaceId({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/organization/folder/create.ts b/packages/sdk/src/cli/commands/organization/folder/create.ts index 1aa96544c..024081763 100644 --- a/packages/sdk/src/cli/commands/organization/folder/create.ts +++ b/packages/sdk/src/cli/commands/organization/folder/create.ts @@ -9,6 +9,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { folderInfo, type FolderInfo } from "../transform"; +// strip unknown keys const createFolderOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), parentFolderId: z.string().optional(), @@ -47,18 +48,16 @@ export async function createFolder(options: CreateFolderOptions): Promise { await assertWritable(); const folder = await createFolder({ diff --git a/packages/sdk/src/cli/commands/organization/folder/delete.ts b/packages/sdk/src/cli/commands/organization/folder/delete.ts index daf3eeddf..08cd3a63e 100644 --- a/packages/sdk/src/cli/commands/organization/folder/delete.ts +++ b/packages/sdk/src/cli/commands/organization/folder/delete.ts @@ -8,6 +8,7 @@ import { prompt } from "#/cli/shared/prompt"; import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; +// strip unknown keys const deleteFolderOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), folderId: z.uuid({ message: "folder-id must be a valid UUID" }), @@ -38,13 +39,11 @@ export async function deleteFolder(options: DeleteFolderOptions): Promise export const deleteCommand = defineAppCommand({ name: "delete", description: "Delete a folder from an organization.", - args: z - .object({ - ...organizationArgs, - ...folderArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...organizationArgs, + ...folderArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable(); const accessToken = await loadAccessToken(); diff --git a/packages/sdk/src/cli/commands/organization/folder/get.ts b/packages/sdk/src/cli/commands/organization/folder/get.ts index 86e9b2998..f813606c0 100644 --- a/packages/sdk/src/cli/commands/organization/folder/get.ts +++ b/packages/sdk/src/cli/commands/organization/folder/get.ts @@ -8,6 +8,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { folderInfo, type FolderInfo } from "../transform"; +// strip unknown keys const getFolderOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), folderId: z.uuid({ message: "folder-id must be a valid UUID" }), @@ -44,12 +45,10 @@ export async function getFolder(options: GetFolderOptions): Promise export const getCommand = defineAppCommand({ name: "get", description: "Show detailed information about a folder.", - args: z - .object({ - ...organizationArgs, - ...folderArgs, - }) - .strict(), + args: z.strictObject({ + ...organizationArgs, + ...folderArgs, + }), run: async (args) => { const folder = await getFolder({ organizationId: args["organization-id"], diff --git a/packages/sdk/src/cli/commands/organization/folder/list.ts b/packages/sdk/src/cli/commands/organization/folder/list.ts index fe35ab345..59f9496aa 100644 --- a/packages/sdk/src/cli/commands/organization/folder/list.ts +++ b/packages/sdk/src/cli/commands/organization/folder/list.ts @@ -8,6 +8,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { folderListInfo, type FolderListInfo } from "../transform"; +// strip unknown keys const listFoldersOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), parentFolderId: z.string().optional(), @@ -54,15 +55,13 @@ export async function listFolders(options: ListFoldersOptions): Promise { const folders = await listFolders({ organizationId: args["organization-id"], diff --git a/packages/sdk/src/cli/commands/organization/folder/update.ts b/packages/sdk/src/cli/commands/organization/folder/update.ts index ee6108550..e147fed60 100644 --- a/packages/sdk/src/cli/commands/organization/folder/update.ts +++ b/packages/sdk/src/cli/commands/organization/folder/update.ts @@ -9,6 +9,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { folderInfo, type FolderInfo } from "../transform"; +// strip unknown keys const updateFolderOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), folderId: z.uuid({ message: "folder-id must be a valid UUID" }), @@ -47,16 +48,14 @@ export async function updateFolder(options: UpdateFolderOptions): Promise { await assertWritable(); const folder = await updateFolder({ diff --git a/packages/sdk/src/cli/commands/organization/get.ts b/packages/sdk/src/cli/commands/organization/get.ts index b57062ea2..9ec49da59 100644 --- a/packages/sdk/src/cli/commands/organization/get.ts +++ b/packages/sdk/src/cli/commands/organization/get.ts @@ -8,6 +8,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { organizationInfo, type OrganizationInfo } from "./transform"; +// strip unknown keys const getOrganizationOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), }); @@ -42,11 +43,9 @@ export async function getOrganization(options: GetOrganizationOptions): Promise< export const getCommand = defineAppCommand({ name: "get", description: "Show detailed information about an organization.", - args: z - .object({ - ...organizationArgs, - }) - .strict(), + args: z.strictObject({ + ...organizationArgs, + }), run: async (args) => { const organization = await getOrganization({ organizationId: args["organization-id"], diff --git a/packages/sdk/src/cli/commands/organization/list.ts b/packages/sdk/src/cli/commands/organization/list.ts index 5f780f90e..4ef3e3832 100644 --- a/packages/sdk/src/cli/commands/organization/list.ts +++ b/packages/sdk/src/cli/commands/organization/list.ts @@ -35,14 +35,12 @@ export async function listOrganizations( export const listCommand = defineAppCommand({ name: "list", description: "List organizations you belong to.", - args: z - .object({ - limit: arg(positiveIntArg.optional(), { - alias: "l", - description: "Maximum number of organizations to list", - }), - }) - .strict(), + args: z.strictObject({ + limit: arg(positiveIntArg.optional(), { + alias: "l", + description: "Maximum number of organizations to list", + }), + }), run: async (args) => { const organizations = await listOrganizations({ limit: args.limit }); logger.out(organizations); diff --git a/packages/sdk/src/cli/commands/organization/tree.ts b/packages/sdk/src/cli/commands/organization/tree.ts index 5c0f931b9..f6d13f658 100644 --- a/packages/sdk/src/cli/commands/organization/tree.ts +++ b/packages/sdk/src/cli/commands/organization/tree.ts @@ -162,19 +162,17 @@ export async function organizationTree( export const treeCommand = defineAppCommand({ name: "tree", description: "Display organization folder hierarchy as a tree.", - args: z - .object({ - "organization-id": arg(z.string().optional(), { - alias: "o", - description: "Organization ID (show all if omitted)", - env: "TAILOR_PLATFORM_ORGANIZATION_ID", - }), - depth: arg(positiveIntArg.optional(), { - alias: "d", - description: "Maximum folder depth to display", - }), - }) - .strict(), + args: z.strictObject({ + "organization-id": arg(z.string().optional(), { + alias: "o", + description: "Organization ID (show all if omitted)", + env: "TAILOR_PLATFORM_ORGANIZATION_ID", + }), + depth: arg(positiveIntArg.optional(), { + alias: "d", + description: "Maximum folder depth to display", + }), + }), run: async (args) => { const accessToken = await loadAccessToken(); const client = await initOperatorClient(accessToken); diff --git a/packages/sdk/src/cli/commands/organization/update.ts b/packages/sdk/src/cli/commands/organization/update.ts index b679313d9..7a9a66692 100644 --- a/packages/sdk/src/cli/commands/organization/update.ts +++ b/packages/sdk/src/cli/commands/organization/update.ts @@ -9,6 +9,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { organizationInfo, type OrganizationInfo } from "./transform"; +// strip unknown keys const updateOrganizationOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), name: z.string().min(1, "Name must not be empty"), @@ -47,15 +48,13 @@ export async function updateOrganization( export const updateCommand = defineAppCommand({ name: "update", description: "Update an organization's name.", - args: z - .object({ - ...organizationArgs, - name: arg(z.string(), { - alias: "n", - description: "New organization name", - }), - }) - .strict(), + args: z.strictObject({ + ...organizationArgs, + name: arg(z.string(), { + alias: "n", + description: "New organization name", + }), + }), run: async (args) => { await assertWritable(); const organization = await updateOrganization({ diff --git a/packages/sdk/src/cli/commands/plugin/index.ts b/packages/sdk/src/cli/commands/plugin/index.ts new file mode 100644 index 000000000..c877f8675 --- /dev/null +++ b/packages/sdk/src/cli/commands/plugin/index.ts @@ -0,0 +1,13 @@ +import { defineCommand, runCommand } from "politty"; +import { listCommand } from "./list"; + +export const pluginCommand = defineCommand({ + name: "plugin", + description: "Manage and inspect CLI plugins (beta).", + subCommands: { + list: listCommand, + }, + async run() { + await runCommand(listCommand, []); + }, +}); diff --git a/packages/sdk/src/cli/commands/plugin/list.ts b/packages/sdk/src/cli/commands/plugin/list.ts new file mode 100644 index 000000000..3fe2133e5 --- /dev/null +++ b/packages/sdk/src/cli/commands/plugin/list.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import { BUILTIN_COMMAND_NAMES } from "#/cli/shared/builtin-commands"; +import { defineAppCommand } from "#/cli/shared/command"; +import { logger } from "#/cli/shared/logger"; +import { readPackageJson } from "#/cli/shared/package-json"; +import { listPlugins } from "#/cli/shared/plugin"; +import ml from "#/utils/multiline"; + +interface PluginListItem { + name: string; + source: "node_modules" | "path"; + path: string; + /** True when a builtin command of the same name shadows this plugin. */ + shadowed: boolean; +} + +export const listCommand = defineAppCommand({ + name: "list", + description: + "List discovered plugins (executables named `-` on PATH or node_modules/.bin).", + args: z.strictObject({}), + run: async () => { + const pkg = await readPackageJson(); + const cliName = Object.keys(pkg.bin ?? {})[0] || "tailor"; + const builtins = new Set(BUILTIN_COMMAND_NAMES); + + const plugins = listPlugins(cliName); + if (plugins.length === 0) { + logger.info(ml` + No plugins found. + Install an executable named "${cliName}-" on your PATH or in node_modules/.bin, + then run it with "${cliName} ". + `); + if (logger.jsonMode) { + logger.out([]); + } + return; + } + + const items: PluginListItem[] = plugins.map((plugin) => ({ + name: plugin.name, + source: plugin.source, + path: plugin.path, + shadowed: builtins.has(plugin.name), + })); + + logger.out(items); + + for (const item of items) { + if (item.shadowed) { + logger.warn( + `Plugin "${cliName}-${item.name}" is shadowed by the builtin "${cliName} ${item.name}" command and will not be dispatched.`, + ); + } + } + }, +}); diff --git a/packages/sdk/src/cli/commands/profile/create.test.ts b/packages/sdk/src/cli/commands/profile/create.test.ts new file mode 100644 index 000000000..98131e8a5 --- /dev/null +++ b/packages/sdk/src/cli/commands/profile/create.test.ts @@ -0,0 +1,124 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { runCommand } from "politty"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { readPlatformConfig, writePlatformConfig } from "#/cli/shared/context"; +import { silenceLogger } from "#/cli/shared/test-helpers/silence-logger"; +import { resetKeyringState } from "#/cli/shared/token-store"; +import { createCommand } from "./create"; +import type * as ClientModule from "#/cli/shared/client"; + +const xdgTempDir = vi.hoisted(() => `/tmp/tailor-profile-create-${Date.now()}-${Math.random()}`); +const keyringPasswords = vi.hoisted(() => new Map()); +const validUUID = "12345678-1234-4abc-8def-123456789012"; + +vi.mock("xdg-basedir", () => ({ xdgConfig: xdgTempDir })); + +vi.mock("@napi-rs/keyring", () => ({ + Entry: class { + private key: string; + constructor(service: string, account: string) { + this.key = `${service}:${account}`; + } + setPassword(password: string) { + keyringPasswords.set(this.key, password); + } + getPassword(): string | null { + return keyringPasswords.get(this.key) ?? null; + } + deletePassword() { + keyringPasswords.delete(this.key); + } + }, +})); + +const clientMocks = vi.hoisted(() => ({ + fetchUserInfo: vi.fn(), + refreshToken: vi.fn(), + initOperatorClient: vi.fn(), + fetchAll: vi.fn(), +})); + +vi.mock("#/cli/shared/client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchUserInfo: clientMocks.fetchUserInfo, + initOperatorClient: clientMocks.initOperatorClient, + fetchAll: clientMocks.fetchAll, + initOAuth2Client: () => ({ refreshToken: clientMocks.refreshToken }), + }; +}); + +beforeAll(() => { + fs.mkdirSync(xdgTempDir, { recursive: true }); +}); + +afterAll(() => { + fs.rmSync(xdgTempDir, { recursive: true, force: true }); +}); + +describe("profile create with a migrating legacy email user", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetKeyringState(); + keyringPasswords.clear(); + vi.stubEnv("TAILOR_PLATFORM_PROFILE", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); + if (fs.existsSync(configPath)) fs.rmSync(configPath); + }); + + test("writes the resolved subject as profile.user when the legacy email key migrates", async () => { + using _logger = silenceLogger("out", "success"); + + const pastDate = new Date(Date.now() - 3600 * 1000).toISOString(); + clientMocks.refreshToken.mockResolvedValue({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: Date.now() + 3600 * 1000, + }); + clientMocks.fetchUserInfo.mockResolvedValue({ + sub: "platform-user-sub", + email: "legacy@example.com", + }); + clientMocks.initOperatorClient.mockResolvedValue({ + listWorkspaces: vi.fn(), + }); + clientMocks.fetchAll.mockResolvedValue([{ id: validUUID }]); + + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "legacy@example.com": { + access_token: "expired-access-token", + refresh_token: "refresh", + token_expires_at: pastDate, + storage: "file", + }, + }, + profiles: {}, + current_user: "legacy@example.com", + }); + + await runCommand(createCommand, [ + "myprofile", + "--user", + "legacy@example.com", + "--workspace-id", + validUUID, + ]); + + const config = await readPlatformConfig(); + expect(config.profiles.myprofile?.user).toBe("platform-user-sub"); + expect(config.users["platform-user-sub"]).toMatchObject({ storage: "keyring" }); + expect(keyringPasswords.get("tailor-platform-cli:platform-user-sub")).toBe( + JSON.stringify({ accessToken: "new-access-token", refreshToken: "new-refresh-token" }), + ); + expect(config.users["legacy@example.com"]).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/cli/commands/profile/create.ts b/packages/sdk/src/cli/commands/profile/create.ts index b84d7bef1..662bf9898 100644 --- a/packages/sdk/src/cli/commands/profile/create.ts +++ b/packages/sdk/src/cli/commands/profile/create.ts @@ -9,47 +9,45 @@ import type { ProfileInfo } from "./types"; export const createCommand = defineAppCommand({ name: "create", description: "Create a new profile.", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Profile name", - }), - user: arg(z.string(), { - alias: "u", - description: "User email", - }), - "workspace-id": arg(z.string(), { - alias: "w", - description: "Workspace ID", - }), - permission: arg(z.enum(["write", "read"]).default("write"), { - description: - "Profile permission. 'read' blocks all write commands while the profile is active.", - }), - "machine-user": arg(z.string().optional(), { - alias: "m", - description: - "Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token).", - }), - "machine-user-override": arg(z.enum(["allow", "deny"]).optional(), { - description: - "Whether the command line or TAILOR_PLATFORM_MACHINE_USER_NAME may override the profile's machine user. 'deny' requires --machine-user.", - }), - "platform-url": arg(z.url().optional(), { - description: "Platform API base URL for this profile.", - env: "TAILOR_PLATFORM_URL", - }), - "oauth2-client-id": arg(z.string().optional(), { - description: "OAuth2 client ID for logging in to this profile's platform.", - env: "TAILOR_PLATFORM_OAUTH2_CLIENT_ID", - }), - "console-url": arg(z.url().optional(), { - description: "Console base URL for this profile.", - env: "TAILOR_PLATFORM_CONSOLE_URL", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Profile name", + }), + user: arg(z.string(), { + alias: "u", + description: "User email address or machine user client ID", + }), + "workspace-id": arg(z.string(), { + alias: "w", + description: "Workspace ID", + }), + permission: arg(z.enum(["write", "read"]).default("write"), { + description: + "Profile permission. 'read' blocks all write commands while the profile is active.", + }), + "machine-user": arg(z.string().optional(), { + alias: "m", + description: + "Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token).", + }), + "machine-user-override": arg(z.enum(["allow", "deny"]).optional(), { + description: + "Whether the command line or TAILOR_PLATFORM_MACHINE_USER_NAME may override the profile's machine user. 'deny' requires --machine-user.", + }), + "platform-url": arg(z.url().optional(), { + description: "Platform API base URL for this profile.", + env: "TAILOR_PLATFORM_URL", + }), + "oauth2-client-id": arg(z.string().optional(), { + description: "OAuth2 client ID for logging in to this profile's platform.", + env: "TAILOR_PLATFORM_OAUTH2_CLIENT_ID", + }), + "console-url": arg(z.url().optional(), { + description: "Console base URL for this profile.", + env: "TAILOR_PLATFORM_CONSOLE_URL", + }), + }), run: async (args) => { if (args["machine-user-override"] === "deny" && !args["machine-user"]) { throw new Error("--machine-user-override deny requires --machine-user."); @@ -70,7 +68,11 @@ export const createCommand = defineAppCommand({ }; const platformConfig = Object.keys(platformConfigInput).length > 0 ? platformConfigInput : undefined; - const token = await fetchLatestToken(config, args.user, platformConfig); + const { accessToken: token, user: resolvedUser } = await fetchLatestToken( + config, + args.user, + platformConfig, + ); // Check if workspace exists const client = await initOperatorClient(token, platformConfig); @@ -89,7 +91,7 @@ export const createCommand = defineAppCommand({ // Create new profile config.profiles[args.name] = { - user: args.user, + user: resolvedUser, workspace_id: args["workspace-id"], ...(args.permission === "read" ? { readonly: true } : {}), ...(args["machine-user"] ? { machine_user: args["machine-user"] } : {}), @@ -109,7 +111,7 @@ export const createCommand = defineAppCommand({ // Show profile info const profileInfo: ProfileInfo = { name: args.name, - user: args.user, + user: resolvedUser, workspaceId: args["workspace-id"], permission: args.permission, ...(args["machine-user"] diff --git a/packages/sdk/src/cli/commands/profile/delete.ts b/packages/sdk/src/cli/commands/profile/delete.ts index 252786e9e..f7d70ea25 100644 --- a/packages/sdk/src/cli/commands/profile/delete.ts +++ b/packages/sdk/src/cli/commands/profile/delete.ts @@ -7,14 +7,12 @@ import { logger } from "#/cli/shared/logger"; export const deleteCommand = defineAppCommand({ name: "delete", description: "Delete a profile.", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Profile name", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Profile name", + }), + }), run: async (args) => { const config = await readPlatformConfig(); diff --git a/packages/sdk/src/cli/commands/profile/list.ts b/packages/sdk/src/cli/commands/profile/list.ts index c755ce79a..f7b84ccf4 100644 --- a/packages/sdk/src/cli/commands/profile/list.ts +++ b/packages/sdk/src/cli/commands/profile/list.ts @@ -9,7 +9,7 @@ import type { ProfileInfo } from "./types"; export const listCommand = defineAppCommand({ name: "list", description: "List all profiles.", - args: z.object({}).strict(), + args: z.strictObject({}), run: async () => { const config = await readPlatformConfig(); const jsonOutput = logger.jsonMode; @@ -18,7 +18,7 @@ export const listCommand = defineAppCommand({ if (profiles.length === 0) { logger.info(ml` No profiles found. - Please create a profile first using 'tailor-sdk profile create' command. + Please create a profile first using 'tailor profile create' command. `); if (jsonOutput) { logger.out([]); diff --git a/packages/sdk/src/cli/commands/profile/update.test.ts b/packages/sdk/src/cli/commands/profile/update.test.ts index 8487111e5..331530606 100644 --- a/packages/sdk/src/cli/commands/profile/update.test.ts +++ b/packages/sdk/src/cli/commands/profile/update.test.ts @@ -109,7 +109,10 @@ describe("profile update --permission", () => { test("performs remote validation when --user is also passed (permission does not bypass it)", async () => { using _logger = silenceLogger("out", "success"); - vi.mocked(fetchLatestToken).mockResolvedValue("mock-token"); + vi.mocked(fetchLatestToken).mockResolvedValue({ + accessToken: "mock-token", + user: "new@example.com", + }); vi.mocked(fetchAll).mockResolvedValue([{ id: validUUID }]); vi.mocked(initOperatorClient).mockResolvedValue({ listWorkspaces: vi.fn(), @@ -133,6 +136,41 @@ describe("profile update --permission", () => { expect(config.profiles.rw?.user).toBe("new@example.com"); expect(config.profiles.rw?.readonly).toBe(true); }); + + test("persists the resolved subject when only --workspace-id is updated for a legacy email user", async () => { + using _logger = silenceLogger("out", "success"); + const newUUID = "abcdef12-3456-4abc-8def-abcdef123456"; + vi.mocked(fetchLatestToken).mockResolvedValue({ + accessToken: "mock-token", + user: "platform-user-sub", + }); + vi.mocked(fetchAll).mockResolvedValue([{ id: newUUID }]); + vi.mocked(initOperatorClient).mockResolvedValue({ + listWorkspaces: vi.fn(), + } as unknown as Awaited>); + + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + rw: { user: "legacy@example.com", workspace_id: validUUID }, + }, + current_user: null, + }); + + await runCommand(updateCommand, ["rw", "--workspace-id", newUUID]); + + expect(vi.mocked(fetchLatestToken)).toHaveBeenCalledWith( + expect.anything(), + "legacy@example.com", + undefined, + ); + + const config = await readPlatformConfig(); + expect(config.profiles.rw?.user).toBe("platform-user-sub"); + expect(config.profiles.rw?.workspace_id).toBe(newUUID); + }); }); describe("profile update --machine-user", () => { @@ -174,7 +212,10 @@ describe("profile update --platform", () => { vi.stubEnv("TAILOR_PLATFORM_URL", undefined); vi.stubEnv("TAILOR_PLATFORM_OAUTH2_CLIENT_ID", undefined); vi.stubEnv("TAILOR_PLATFORM_CONSOLE_URL", undefined); - vi.mocked(fetchLatestToken).mockResolvedValue("mock-token"); + vi.mocked(fetchLatestToken).mockResolvedValue({ + accessToken: "mock-token", + user: "u@example.com", + }); vi.mocked(fetchAll).mockResolvedValue([{ id: validUUID }]); vi.mocked(initOperatorClient).mockResolvedValue({ listWorkspaces: vi.fn(), diff --git a/packages/sdk/src/cli/commands/profile/update.ts b/packages/sdk/src/cli/commands/profile/update.ts index 1ff6cb2ef..dc3884610 100644 --- a/packages/sdk/src/cli/commands/profile/update.ts +++ b/packages/sdk/src/cli/commands/profile/update.ts @@ -14,45 +14,43 @@ import type { ProfileInfo } from "./types"; export const updateCommand = defineAppCommand({ name: "update", description: "Update profile properties.", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Profile name", - }), - user: arg(z.string().optional(), { - alias: "u", - description: "New user email", - }), - "workspace-id": arg(z.string().optional(), { - alias: "w", - description: "New workspace ID", - }), - permission: arg(z.enum(["write", "read"]).optional(), { - description: - "Profile permission. 'read' blocks all write commands; 'write' lifts the restriction.", - }), - "machine-user": arg(z.string().optional(), { - alias: "m", - description: - "Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token). Pass an empty string to clear.", - }), - "machine-user-override": arg(z.enum(["allow", "deny"]).optional(), { - description: - "Whether the command line or TAILOR_PLATFORM_MACHINE_USER_NAME may override the profile's machine user. 'deny' requires --machine-user; 'allow' lifts the restriction.", - }), - "platform-url": arg(z.union([z.url(), z.literal("")]).optional(), { - description: "Platform API base URL for this profile. Pass an empty string to clear.", - }), - "oauth2-client-id": arg(z.string().optional(), { - description: - "OAuth2 client ID for logging in to this profile's platform. Pass an empty string to clear.", - }), - "console-url": arg(z.union([z.url(), z.literal("")]).optional(), { - description: "Console base URL for this profile. Pass an empty string to clear.", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Profile name", + }), + user: arg(z.string().optional(), { + alias: "u", + description: "New user email address or machine user client ID", + }), + "workspace-id": arg(z.string().optional(), { + alias: "w", + description: "New workspace ID", + }), + permission: arg(z.enum(["write", "read"]).optional(), { + description: + "Profile permission. 'read' blocks all write commands; 'write' lifts the restriction.", + }), + "machine-user": arg(z.string().optional(), { + alias: "m", + description: + "Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token). Pass an empty string to clear.", + }), + "machine-user-override": arg(z.enum(["allow", "deny"]).optional(), { + description: + "Whether the command line or TAILOR_PLATFORM_MACHINE_USER_NAME may override the profile's machine user. 'deny' requires --machine-user; 'allow' lifts the restriction.", + }), + "platform-url": arg(z.union([z.url(), z.literal("")]).optional(), { + description: "Platform API base URL for this profile. Pass an empty string to clear.", + }), + "oauth2-client-id": arg(z.string().optional(), { + description: + "OAuth2 client ID for logging in to this profile's platform. Pass an empty string to clear.", + }), + "console-url": arg(z.union([z.url(), z.literal("")]).optional(), { + description: "Console base URL for this profile. Pass an empty string to clear.", + }), + }), run: async (args) => { const config = await readPlatformConfig(); @@ -79,6 +77,7 @@ export const updateCommand = defineAppCommand({ const newUser = args.user || oldUser; const oldWorkspaceId = profile.workspace_id; const newWorkspaceId = args["workspace-id"] || oldWorkspaceId; + let resolvedUser = newUser; // Compute the final machine_user and machine_user_override to validate the combination. const finalMachineUser = @@ -128,10 +127,11 @@ export const updateCommand = defineAppCommand({ args["platform-url"] !== undefined ) { // Check if user exists - const token = await fetchLatestToken(config, newUser, tokenLookupPlatformConfig); + const refreshed = await fetchLatestToken(config, newUser, tokenLookupPlatformConfig); + resolvedUser = refreshed.user; // Check if workspace exists - const client = await initOperatorClient(token, finalPlatformConfig); + const client = await initOperatorClient(refreshed.accessToken, finalPlatformConfig); const workspaces = await fetchAll(async (pageToken, maxPageSize) => { const { workspaces, nextPageToken } = await client.listWorkspaces({ pageToken, @@ -146,7 +146,7 @@ export const updateCommand = defineAppCommand({ } // Update properties - profile.user = newUser; + profile.user = resolvedUser; profile.workspace_id = newWorkspaceId; if (args.permission === "read") { profile.readonly = true; @@ -194,7 +194,7 @@ export const updateCommand = defineAppCommand({ // Show profile info const profileInfo: ProfileInfo = { name: args.name, - user: newUser, + user: resolvedUser, workspaceId: newWorkspaceId, permission: profile.readonly === true ? "read" : "write", ...(profile.machine_user diff --git a/packages/sdk/src/cli/commands/remove.ts b/packages/sdk/src/cli/commands/remove.ts index d641a4c7d..0eda14527 100644 --- a/packages/sdk/src/cli/commands/remove.ts +++ b/packages/sdk/src/cli/commands/remove.ts @@ -185,12 +185,10 @@ export async function remove(options?: RemoveOptions): Promise { export const removeCommand = defineAppCommand({ name: "remove", description: "Remove all resources managed by the application from the workspace.", - args: z - .object({ - ...deploymentArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const { client, workspaceId, application, config } = await loadOptions({ diff --git a/packages/sdk/src/cli/commands/secret/create.ts b/packages/sdk/src/cli/commands/secret/create.ts index 4c09f810e..f72ea18e6 100644 --- a/packages/sdk/src/cli/commands/secret/create.ts +++ b/packages/sdk/src/cli/commands/secret/create.ts @@ -13,13 +13,11 @@ import { checkVaultManaged, releaseVaultOwnership } from "./check-vault-managed" export const createSecretCommand = defineAppCommand({ name: "create", description: "Create a secret in a vault.", - args: z - .object({ - ...workspaceArgs, - ...secretValueArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...secretValueArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/secret/delete.ts b/packages/sdk/src/cli/commands/secret/delete.ts index b69f3e6cc..86ccad56b 100644 --- a/packages/sdk/src/cli/commands/secret/delete.ts +++ b/packages/sdk/src/cli/commands/secret/delete.ts @@ -13,13 +13,11 @@ import { checkVaultManaged, releaseVaultOwnership } from "./check-vault-managed" export const deleteSecretCommand = defineAppCommand({ name: "delete", description: "Delete a secret in a vault.", - args: z - .object({ - ...workspaceArgs, - ...secretIdentifyArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...secretIdentifyArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/secret/list.ts b/packages/sdk/src/cli/commands/secret/list.ts index fa97d8c6c..9d9c1a401 100644 --- a/packages/sdk/src/cli/commands/secret/list.ts +++ b/packages/sdk/src/cli/commands/secret/list.ts @@ -67,13 +67,11 @@ async function secretList(options: SecretListOptions): Promise { export const listSecretCommand = defineAppCommand({ name: "list", description: "List all secrets in a vault.", - args: z - .object({ - ...workspaceArgs, - ...vaultArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...vaultArgs, + ...paginationArgs(), + }), run: async (args) => { try { const secrets = await secretList({ diff --git a/packages/sdk/src/cli/commands/secret/update.ts b/packages/sdk/src/cli/commands/secret/update.ts index 94c53441b..0a58e1ab5 100644 --- a/packages/sdk/src/cli/commands/secret/update.ts +++ b/packages/sdk/src/cli/commands/secret/update.ts @@ -13,13 +13,11 @@ import { checkVaultManaged, releaseVaultOwnership } from "./check-vault-managed" export const updateSecretCommand = defineAppCommand({ name: "update", description: "Update a secret in a vault.", - args: z - .object({ - ...workspaceArgs, - ...secretValueArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...secretValueArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/secret/vault/create.ts b/packages/sdk/src/cli/commands/secret/vault/create.ts index d5bd0a9b6..4cbba85e5 100644 --- a/packages/sdk/src/cli/commands/secret/vault/create.ts +++ b/packages/sdk/src/cli/commands/secret/vault/create.ts @@ -11,12 +11,10 @@ import { nameArgs } from "./args"; export const createCommand = defineAppCommand({ name: "create", description: "Create a new Secret Manager vault.", - args: z - .object({ - ...workspaceArgs, - ...nameArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...nameArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/secret/vault/delete.ts b/packages/sdk/src/cli/commands/secret/vault/delete.ts index 06554e52a..162ec2ef3 100644 --- a/packages/sdk/src/cli/commands/secret/vault/delete.ts +++ b/packages/sdk/src/cli/commands/secret/vault/delete.ts @@ -13,13 +13,11 @@ import { nameArgs } from "./args"; export const deleteCommand = defineAppCommand({ name: "delete", description: "Delete a Secret Manager vault.", - args: z - .object({ - ...workspaceArgs, - ...nameArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...nameArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/secret/vault/list.ts b/packages/sdk/src/cli/commands/secret/vault/list.ts index 84432301d..866aad478 100644 --- a/packages/sdk/src/cli/commands/secret/vault/list.ts +++ b/packages/sdk/src/cli/commands/secret/vault/list.ts @@ -63,12 +63,10 @@ async function vaultList(options?: VaultListOptions): Promise { export const listCommand = defineAppCommand({ name: "list", description: "List all Secret Manager vaults in the workspace.", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const vaults = await vaultList({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/setup/branch.workflow.yml b/packages/sdk/src/cli/commands/setup/branch.workflow.yml index 80fb30940..264ff2a1d 100644 --- a/packages/sdk/src/cli/commands/setup/branch.workflow.yml +++ b/packages/sdk/src/cli/commands/setup/branch.workflow.yml @@ -164,8 +164,8 @@ jobs: return normalized; } - const headTarget = lockTarget(".github/tailor-sdk.lock"); - const baseTarget = lockTarget(".tailor-erd-base/.github/tailor-sdk.lock"); + const headTarget = lockTarget(".github/tailor.lock"); + const baseTarget = lockTarget(".tailor-erd-base/.github/tailor.lock"); const baseAppDir = normalizeDir(baseTarget?.inputs?.dir, "base setup lock"); const namespaces = [ ...new Set([ @@ -323,12 +323,12 @@ jobs: run: | set -euo pipefail - run_tailor_sdk() { + run_tailor_cli() { case "__PACKAGE_MANAGER__" in - pnpm) pnpm exec tailor-sdk "$@" ;; - npm) npx tailor-sdk "$@" ;; - yarn) yarn tailor-sdk "$@" ;; - bun) bunx tailor-sdk "$@" ;; + pnpm) pnpm exec tailor "$@" ;; + npm) npx tailor "$@" ;; + yarn) yarn tailor "$@" ;; + bun) bunx tailor "$@" ;; *) echo "::error::Unsupported package-manager '__PACKAGE_MANAGER__' (expected pnpm, npm, yarn, or bun)" exit 1 @@ -349,7 +349,7 @@ jobs: esac } - tailor_sdk_bin="$( + tailor_cli_bin="$( run_head_node - <<'NODE' const path = require("node:path"); const { createRequire } = require("node:module"); @@ -381,12 +381,12 @@ jobs: NODE )" - run_head_tailor_sdk_bin() { + run_head_tailor_cli_bin() { local command_cwd="$1" shift run_head_cli_env() { - env TAILOR_SDK_BIN="$tailor_sdk_bin" TAILOR_SDK_CWD="$command_cwd" "$@" + env TAILOR_CLI_BIN="$tailor_cli_bin" TAILOR_CLI_CWD="$command_cwd" "$@" } ( @@ -404,12 +404,12 @@ jobs: ) } - run_base_tailor_sdk_bin() { + run_base_tailor_cli_bin() { local command_cwd="$1" shift run_head_cli_env() { - env TAILOR_SDK_BIN="$tailor_sdk_bin" TAILOR_SDK_CWD="$command_cwd" "$@" + env TAILOR_CLI_BIN="$tailor_cli_bin" TAILOR_CLI_CWD="$command_cwd" "$@" } ( @@ -431,7 +431,7 @@ jobs: base_output="$RUNNER_TEMP/tailor-erd/base" preview_output="$RUNNER_TEMP/tailor-erd/preview" dts_output="$RUNNER_TEMP/tailor-erd/dts" - head_cli_runner="$RUNNER_TEMP/tailor-erd/run-head-tailor-sdk.mjs" + head_cli_runner="$RUNNER_TEMP/tailor-erd/run-head-cli.mjs" head_log="$RUNNER_TEMP/tailor-erd/head-$NAMESPACE.log" base_log="$RUNNER_TEMP/tailor-erd/base-$NAMESPACE.log" rm -rf "$head_output" "$base_output" "$preview_output" "$dts_output" @@ -439,10 +439,10 @@ jobs: cat > "$head_cli_runner" <<'NODE' import { pathToFileURL } from "node:url"; - const cliBin = process.env.TAILOR_SDK_BIN; - const commandCwd = process.env.TAILOR_SDK_CWD; + const cliBin = process.env.TAILOR_CLI_BIN; + const commandCwd = process.env.TAILOR_CLI_CWD; if (!cliBin || !commandCwd) { - throw new Error("TAILOR_SDK_BIN and TAILOR_SDK_CWD are required."); + throw new Error("TAILOR_CLI_BIN and TAILOR_CLI_CWD are required."); } process.chdir(commandCwd); @@ -463,7 +463,7 @@ jobs: elif ! ( cd "$GITHUB_WORKSPACE/$APP_DIR" || exit 1 TAILOR_PLATFORM_SDK_DTS_PATH="$dts_output/head-$NAMESPACE.d.ts" \ - run_tailor_sdk tailordb erd export --config "$head_config" --namespace "$NAMESPACE" --output "$head_output" + run_tailor_cli tailordb erd export --config "$head_config" --namespace "$NAMESPACE" --output "$head_output" ) >"$head_log" 2>&1; then if grep -q 'not found in local config.db' "$head_log"; then cat "$head_log" @@ -483,7 +483,7 @@ jobs: base_missing="true" elif ! ( TAILOR_PLATFORM_SDK_DTS_PATH="$dts_output/base-$NAMESPACE.d.ts" \ - run_base_tailor_sdk_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config" --namespace "$NAMESPACE" --output "$base_output" + run_base_tailor_cli_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config" --namespace "$NAMESPACE" --output "$base_output" ) >"$base_log" 2>&1; then if grep -q 'not found in local config.db' "$base_log"; then cat "$base_log" @@ -517,7 +517,7 @@ jobs: if [ "$base_missing" = "false" ]; then diff_args+=(--base-html "$base_html") fi - run_head_tailor_sdk_bin "$GITHUB_WORKSPACE" tailordb erd diff "${diff_args[@]}" + run_head_tailor_cli_bin "$GITHUB_WORKSPACE" tailordb erd diff "${diff_args[@]}" - id: tailor-upload-erd-viewer uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/packages/sdk/src/cli/commands/setup/check.ts b/packages/sdk/src/cli/commands/setup/check.ts index cdca521b0..ed3b42c65 100644 --- a/packages/sdk/src/cli/commands/setup/check.ts +++ b/packages/sdk/src/cli/commands/setup/check.ts @@ -281,8 +281,8 @@ export async function checkGitHub(options: CheckGitHubOptions): Promise { const lock = readLock(outputDir); if (!lock || lock.targets.length === 0) { throw new Error( - "No managed workflows found (.github/tailor-sdk.lock is missing or empty). " + - "Run `tailor-sdk setup branch` (or another setup subcommand) first.", + "No managed workflows found (.github/tailor.lock is missing or empty). " + + "Run `tailor setup branch` (or another setup subcommand) first.", ); } @@ -301,7 +301,7 @@ export async function checkGitHub(options: CheckGitHubOptions): Promise { throw new Error( "TAILOR_PLATFORM_WORKSPACE_ID is not set. " + "Provision the workspace and set the variable:\n" + - " tailor-sdk workspace create # if it does not exist yet; copy the id\n" + + " tailor workspace create # if it does not exist yet; copy the id\n" + " gh variable set TAILOR_PLATFORM_WORKSPACE_ID --env ", ); } @@ -389,6 +389,6 @@ export async function checkGitHub(options: CheckGitHubOptions): Promise { } throw new Error( `Detected ${String(findings.length)} drift finding(s) across ${String(count)} target(s). ` + - "Re-run `tailor-sdk setup` to regenerate, or address each finding above.", + "Re-run `tailor setup` to regenerate, or address each finding above.", ); } diff --git a/packages/sdk/src/cli/commands/setup/generate.test.ts b/packages/sdk/src/cli/commands/setup/generate.test.ts index ab4f62bae..d71486fd0 100644 --- a/packages/sdk/src/cli/commands/setup/generate.test.ts +++ b/packages/sdk/src/cli/commands/setup/generate.test.ts @@ -187,9 +187,9 @@ describe("renderBranchWorkflow", () => { expect(content).toContain( "namespace: ${{ fromJSON(needs.tailor-erd-preview-matrix.outputs.namespaces) }}", ); - expect(content).toContain(".tailor-erd-base/.github/tailor-sdk.lock"); - expect(content).toContain("run_tailor_sdk tailordb erd export"); - expect(content).toContain('run_head_tailor_sdk_bin "$GITHUB_WORKSPACE" tailordb erd diff'); + expect(content).toContain(".tailor-erd-base/.github/tailor.lock"); + expect(content).toContain("run_tailor_cli tailordb erd export"); + expect(content).toContain('run_head_tailor_cli_bin "$GITHUB_WORKSPACE" tailordb erd diff'); expect(content).toContain("const namespacePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;"); expect(content).toContain("Invalid ERD namespace in"); expect(content).toContain("id: tailor-detect-base-package-manager"); @@ -209,12 +209,12 @@ describe("renderBranchWorkflow", () => { 'base_config="$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR/tailor.config.ts"', ); expect(content).toContain("BASE_PACKAGE_MANAGER:"); - expect(content).toContain("tailor_sdk_bin"); + expect(content).toContain("tailor_cli_bin"); expect(content).toContain("run_head_node - <<'NODE'"); expect(content).toContain('path.join(githubWorkspace, appDir, "package.json")'); expect(content).toContain('path.join(githubWorkspace, "package.json")'); expect(content).toContain( - 'run_base_tailor_sdk_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', + 'run_base_tailor_cli_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', ); expect(content).toContain('yarn) run_head_cli_env yarn node "$head_cli_runner" "$@" ;;'); expect(content).toContain('head_missing="false"'); @@ -313,8 +313,8 @@ describe("renderBranchWorkflow", () => { expect(start).toBeGreaterThanOrEqual(0); expect(end).toBeGreaterThan(start); - expect(buildStep).toContain("run_head_tailor_sdk_bin() {"); - expect(buildStep).toContain("run_base_tailor_sdk_bin() {"); + expect(buildStep).toContain("run_head_tailor_cli_bin() {"); + expect(buildStep).toContain("run_base_tailor_cli_bin() {"); expect(buildStep).toContain('cd "$GITHUB_WORKSPACE"'); expect(buildStep).toContain('local command_cwd="$1"'); expect(buildStep).toContain("process.chdir(commandCwd);"); @@ -326,7 +326,7 @@ describe("renderBranchWorkflow", () => { expect(buildStep).toContain('case "$BASE_PACKAGE_MANAGER" in'); expect(buildStep).toContain('cd "$command_cwd"'); expect(buildStep).toContain( - 'run_base_tailor_sdk_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', + 'run_base_tailor_cli_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', ); }); @@ -349,7 +349,7 @@ describe("renderBranchWorkflow", () => { 'base_config="$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR/tailor.config.ts"', ); expect(buildStep).toContain( - 'run_base_tailor_sdk_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', + 'run_base_tailor_cli_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', ); expect(buildStep).not.toContain(".tailor-erd-base/$APP_DIR/tailor.config.ts"); expect(buildStep).not.toContain('.tailor-erd-base/$APP_DIR" tailordb erd export'); @@ -380,9 +380,9 @@ describe("renderBranchWorkflow", () => { 'cd "$GITHUB_WORKSPACE/$APP_DIR"\n TAILOR_PLATFORM_SDK_DTS_PATH', ); expect(buildStep).not.toContain( - 'cd "$GITHUB_WORKSPACE/$APP_DIR"\n run_tailor_sdk tailordb erd diff', + 'cd "$GITHUB_WORKSPACE/$APP_DIR"\n run_tailor_cli tailordb erd diff', ); - expect(buildStep).toContain('run_head_tailor_sdk_bin "$GITHUB_WORKSPACE" tailordb erd diff'); + expect(buildStep).toContain('run_head_tailor_cli_bin "$GITHUB_WORKSPACE" tailordb erd diff'); }); test("treats missing ERD preview configs as empty diff sides", () => { @@ -419,7 +419,7 @@ describe("renderBranchWorkflow", () => { const { content } = renderBranchWorkflow(branchBase); expect(content).toContain("uses: tailor-platform/actions/generate-check@"); expect(content).not.toContain("git add -A"); - expect(content).not.toContain("tailor-sdk generate"); + expect(content).not.toContain("tailor generate"); }); test("includes paths + working-directory only when dir != '.'", () => { @@ -1074,13 +1074,13 @@ describe("setupCoordinate", () => { }); test("errors when lock file is missing", async () => { - await expect(setupCoordinate(coordinateOpts())).rejects.toThrow(/tailor-sdk\.lock not found/); + await expect(setupCoordinate(coordinateOpts())).rejects.toThrow(/tailor\.lock not found/); }); test("errors when an action target is not in the lock", async () => { await setupTarget(actionOpts("api")); await expect(setupCoordinate(coordinateOpts({ actions: ["missing-app"] }))).rejects.toThrow( - /not found in .github\/tailor-sdk\.lock/, + /not found in .github\/tailor\.lock/, ); }); diff --git a/packages/sdk/src/cli/commands/setup/generate.ts b/packages/sdk/src/cli/commands/setup/generate.ts index fbdfcfe0d..125e90f5e 100644 --- a/packages/sdk/src/cli/commands/setup/generate.ts +++ b/packages/sdk/src/cli/commands/setup/generate.ts @@ -597,13 +597,13 @@ function printNextSteps(obj: { environment: string; idInjected: boolean }): void `2. Provision the workspace and set its id as the TAILOR_PLATFORM_WORKSPACE_ID variable ` + `on the "${environment}" environment:`, ); - logger.log(" tailor-sdk workspace create # if it does not exist yet; copy the id"); + logger.log(" tailor workspace create # if it does not exist yet; copy the id"); logger.log(` gh variable set TAILOR_PLATFORM_WORKSPACE_ID --env ${environment}`); logger.newline(); logger.log("3. Commit the generated files:"); logger.log(" - .github/workflows/tailor-*.yml"); - logger.log(" - .github/tailor-sdk.lock"); + logger.log(" - .github/tailor.lock"); if (idInjected) { logger.log(" - tailor.config.ts (app id was added)"); } @@ -711,7 +711,7 @@ export async function setupTarget(options: SetupTargetOptions): Promise { logger.newline(); logger.log(`The composite action has been generated at ${styles.path(resolved.file)}.`); logger.log( - "Use `tailor-sdk setup coordinate` to generate a coordinator workflow that orchestrates this action.", + "Use `tailor setup coordinate` to generate a coordinator workflow that orchestrates this action.", ); } else { printNextSteps({ environment: resolved.environment, idInjected }); @@ -735,7 +735,7 @@ export async function setupCoordinate(options: CoordinateSetupOptions): Promise< if (actions.length === 0) { throw new Error( "At least one --action is required. " + - "Run `tailor-sdk setup action --dir ` for each app first.", + "Run `tailor setup action --dir ` for each app first.", ); } @@ -763,8 +763,8 @@ export async function setupCoordinate(options: CoordinateSetupOptions): Promise< if (!lock) { throw new Error( - ".github/tailor-sdk.lock not found. " + - "Run `tailor-sdk setup action --name ` for each app before running setup coordinate.", + ".github/tailor.lock not found. " + + "Run `tailor setup action --name ` for each app before running setup coordinate.", ); } @@ -787,14 +787,14 @@ export async function setupCoordinate(options: CoordinateSetupOptions): Promise< const entry = actionTargets.get(name); if (!entry) { throw new Error( - `Action target "${name}" not found in .github/tailor-sdk.lock. ` + - `Run \`tailor-sdk setup action --name ${name}\` first.`, + `Action target "${name}" not found in .github/tailor.lock. ` + + `Run \`tailor setup action --name ${name}\` first.`, ); } if (names.length > 1 && entry.templateVersion < TEMPLATE_VERSION) { throw new Error( `Action target "${name}" was generated with an older setup template. ` + - `Run \`tailor-sdk setup action --name ${name} --force\` before grouping it in setup coordinate.`, + `Run \`tailor setup action --name ${name} --force\` before grouping it in setup coordinate.`, ); } validateDir(entry.inputs.dir); @@ -893,11 +893,11 @@ export async function setupCoordinate(options: CoordinateSetupOptions): Promise< logger.log( `2. Provision the target workspace and set TAILOR_PLATFORM_WORKSPACE_ID on the "${environment}" environment:`, ); - logger.log(" tailor-sdk workspace create # if it does not exist yet; copy the id"); + logger.log(" tailor workspace create # if it does not exist yet; copy the id"); logger.log(` gh variable set TAILOR_PLATFORM_WORKSPACE_ID --env ${environment}`); logger.newline(); logger.log("3. Commit the generated files:"); logger.log(` - ${file}`); logger.log(` - ${tailorSetupFile} (if newly created)`); - logger.log(" - .github/tailor-sdk.lock"); + logger.log(" - .github/tailor.lock"); } diff --git a/packages/sdk/src/cli/commands/setup/index.ts b/packages/sdk/src/cli/commands/setup/index.ts index ceb07375f..fef93f569 100644 --- a/packages/sdk/src/cli/commands/setup/index.ts +++ b/packages/sdk/src/cli/commands/setup/index.ts @@ -9,15 +9,13 @@ import { setupCoordinate, setupTarget } from "./generate"; const checkCommand = defineAppCommand({ name: "check", description: "Audit generated workflows for drift against the current config/repo (read-only).", - args: z - .object({ - ci: arg(z.boolean().default(false), { - description: - "Run in CI mode: skip checks that are handled by the runtime " + - "(e.g. TAILOR_PLATFORM_WORKSPACE_ID).", - }), - }) - .strict(), + args: z.strictObject({ + ci: arg(z.boolean().default(false), { + description: + "Run in CI mode: skip checks that are handled by the runtime " + + "(e.g. TAILOR_PLATFORM_WORKSPACE_ID).", + }), + }), run: async (args) => { await checkGitHub({ outputDir: process.cwd(), ci: args.ci }); }, @@ -27,31 +25,28 @@ const coordinateCommand = defineAppCommand({ name: "coordinate", description: "Generate a coordinator workflow that orchestrates multiple --action-generated composite actions.", - args: z - .object({ - name: arg(z.string().min(1), { - alias: "n", - description: "Coordinator name (used in the generated workflow file name and job names)", - }), - action: arg(z.array(z.string().min(1)).min(1), { - description: - "Composite action to include. Repeat for separate deploy steps, or use commas to deploy actions as one multi-config group. tailor- prefix optional.", - }), - branch: arg(z.string().min(1).optional(), { - description: - "Branch target: deploy trigger branch (defaults to the detected default branch)", - }), - tag: arg(z.boolean().default(false), { - description: "Generate a tag target coordinator", - }), - environment: arg(z.string().min(1).optional(), { - description: "GitHub Environment for the plan/deploy jobs", - }), - force: arg(z.boolean().default(false), { - description: "Discard hand edits and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().min(1), { + alias: "n", + description: "Coordinator name (used in the generated workflow file name and job names)", + }), + action: arg(z.array(z.string().min(1)).min(1), { + description: + "Composite action to include. Repeat for separate deploy steps, or use commas to deploy actions as one multi-config group. tailor- prefix optional.", + }), + branch: arg(z.string().min(1).optional(), { + description: "Branch target: deploy trigger branch (defaults to the detected default branch)", + }), + tag: arg(z.boolean().default(false), { + description: "Generate a tag target coordinator", + }), + environment: arg(z.string().min(1).optional(), { + description: "GitHub Environment for the plan/deploy jobs", + }), + force: arg(z.boolean().default(false), { + description: "Discard hand edits and regenerate", + }), + }), run: async (args) => { const coordinateKind = args.tag ? "tag" : "branch"; await setupCoordinate({ @@ -70,24 +65,22 @@ const actionCommand = defineAppCommand({ name: "action", description: "Generate a per-app composite action for use with setup coordinate (monorepo multi-app deploys).", - args: z - .object({ - name: arg(z.string().min(1).optional(), { - alias: "n", - description: "Name (defaults to the config 'name')", - }), - dir: arg(z.string().min(1).default("."), { - alias: "d", - description: "App directory", - }), - environment: arg(z.string().min(1).optional(), { - description: "GitHub Environment (defaults to the workspace name)", - }), - force: arg(z.boolean().default(false), { - description: "Discard hand edits and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().min(1).optional(), { + alias: "n", + description: "Name (defaults to the config 'name')", + }), + dir: arg(z.string().min(1).default("."), { + alias: "d", + description: "App directory", + }), + environment: arg(z.string().min(1).optional(), { + description: "GitHub Environment (defaults to the workspace name)", + }), + force: arg(z.boolean().default(false), { + description: "Discard hand edits and regenerate", + }), + }), run: async (args) => { await setupTarget({ kind: "action", @@ -103,31 +96,28 @@ const actionCommand = defineAppCommand({ const branchCommand = defineAppCommand({ name: "branch", description: "Generate a branch-target deploy workflow (push to branch triggers deploy).", - args: z - .object({ - name: arg(z.string().min(1).optional(), { - alias: "n", - description: "Name (defaults to the config 'name')", - }), - branch: arg(z.string().min(1).optional(), { - description: "Deploy trigger branch (defaults to the detected default branch)", - }), - environment: arg(z.string().min(1).optional(), { - description: "GitHub Environment for the plan/deploy jobs (defaults to the workspace name)", - }), - "erd-preview": arg(z.boolean().default(false), { - description: - "Add PR ERD viewer artifacts with current/diff previews for TailorDB namespaces", - }), - dir: arg(z.string().min(1).default("."), { - alias: "d", - description: "App directory (for monorepo setups)", - }), - force: arg(z.boolean().default(false), { - description: "Discard hand edits / take over unmanaged files and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().min(1).optional(), { + alias: "n", + description: "Name (defaults to the config 'name')", + }), + branch: arg(z.string().min(1).optional(), { + description: "Deploy trigger branch (defaults to the detected default branch)", + }), + environment: arg(z.string().min(1).optional(), { + description: "GitHub Environment for the plan/deploy jobs (defaults to the workspace name)", + }), + "erd-preview": arg(z.boolean().default(false), { + description: "Add PR ERD viewer artifacts with current/diff previews for TailorDB namespaces", + }), + dir: arg(z.string().min(1).default("."), { + alias: "d", + description: "App directory (for monorepo setups)", + }), + force: arg(z.boolean().default(false), { + description: "Discard hand edits / take over unmanaged files and regenerate", + }), + }), run: async (args) => { await setupTarget({ kind: "branch", @@ -145,30 +135,28 @@ const branchCommand = defineAppCommand({ const tagCommand = defineAppCommand({ name: "tag", description: "Generate a tag-target deploy workflow (tag push triggers deploy).", - args: z - .object({ - name: arg(z.string().min(1).optional(), { - alias: "n", - description: "Name (defaults to the config 'name')", - }), - "tag-pattern": arg(z.string().min(1).default("v*"), { - description: "Tag glob to match (defaults to v*)", - }), - branch: arg(z.string().min(1).optional(), { - description: "Tag-reachability guard branch (no guard when omitted)", - }), - environment: arg(z.string().min(1).optional(), { - description: "GitHub Environment for the plan/deploy jobs (defaults to the workspace name)", - }), - dir: arg(z.string().min(1).default("."), { - alias: "d", - description: "App directory (for monorepo setups)", - }), - force: arg(z.boolean().default(false), { - description: "Discard hand edits / take over unmanaged files and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().min(1).optional(), { + alias: "n", + description: "Name (defaults to the config 'name')", + }), + "tag-pattern": arg(z.string().min(1).default("v*"), { + description: "Tag glob to match (defaults to v*)", + }), + branch: arg(z.string().min(1).optional(), { + description: "Tag-reachability guard branch (no guard when omitted)", + }), + environment: arg(z.string().min(1).optional(), { + description: "GitHub Environment for the plan/deploy jobs (defaults to the workspace name)", + }), + dir: arg(z.string().min(1).default("."), { + alias: "d", + description: "App directory (for monorepo setups)", + }), + force: arg(z.boolean().default(false), { + description: "Discard hand edits / take over unmanaged files and regenerate", + }), + }), run: async (args) => { await setupTarget({ kind: "tag", @@ -186,33 +174,31 @@ const tagCommand = defineAppCommand({ const previewCommand = defineAppCommand({ name: "preview", description: "Generate a preview workflow (PR open/sync triggers deploy to a per-PR workspace).", - args: z - .object({ - name: arg(z.string().min(1).optional(), { - alias: "n", - description: "Name (defaults to the config 'name')", - }), - branch: arg(z.string().min(1).optional(), { - description: "Branch to filter PRs by (defaults to the detected default branch)", - }), - region: arg(z.string().min(1), { - description: "Workspace region for preview workspace creation (e.g. us-west). Required.", - }), - "require-preview-label": arg(z.boolean().default(false), { - description: "Deploy preview only for PRs labeled `tailor:preview` instead of all PRs.", - }), - environment: arg(z.string().min(1).optional(), { - description: "GitHub Environment for the preview jobs (defaults to the workspace name)", - }), - dir: arg(z.string().min(1).default("."), { - alias: "d", - description: "App directory (for monorepo setups)", - }), - force: arg(z.boolean().default(false), { - description: "Discard hand edits / take over unmanaged files and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().min(1).optional(), { + alias: "n", + description: "Name (defaults to the config 'name')", + }), + branch: arg(z.string().min(1).optional(), { + description: "Branch to filter PRs by (defaults to the detected default branch)", + }), + region: arg(z.string().min(1), { + description: "Workspace region for preview workspace creation (e.g. us-west). Required.", + }), + "require-preview-label": arg(z.boolean().default(false), { + description: "Deploy preview only for PRs labeled `tailor:preview` instead of all PRs.", + }), + environment: arg(z.string().min(1).optional(), { + description: "GitHub Environment for the preview jobs (defaults to the workspace name)", + }), + dir: arg(z.string().min(1).default("."), { + alias: "d", + description: "App directory (for monorepo setups)", + }), + force: arg(z.boolean().default(false), { + description: "Discard hand edits / take over unmanaged files and regenerate", + }), + }), run: async (args) => { await setupTarget({ kind: "preview", @@ -230,17 +216,15 @@ const previewCommand = defineAppCommand({ const deleteCommand = defineAppCommand({ name: "delete", - description: "Delete managed workflow/action file(s) and their .github/tailor-sdk.lock entries.", - args: z - .object({ - ...confirmationArgs, - files: arg(z.string().array().min(1), { - positional: true, - description: - "Workflow/action file(s) to delete, as generated under .github/workflows or .github/actions", - }), - }) - .strict(), + description: "Delete managed workflow/action file(s) and their .github/tailor.lock entries.", + args: z.strictObject({ + ...confirmationArgs, + files: arg(z.string().array().min(1), { + positional: true, + description: + "Workflow/action file(s) to delete, as generated under .github/workflows or .github/actions", + }), + }), run: async (args) => { await setupDelete({ files: args.files, yes: args.yes, outputDir: process.cwd() }); }, diff --git a/packages/sdk/src/cli/commands/setup/lock.test.ts b/packages/sdk/src/cli/commands/setup/lock.test.ts index c83ad626a..35fded175 100644 --- a/packages/sdk/src/cli/commands/setup/lock.test.ts +++ b/packages/sdk/src/cli/commands/setup/lock.test.ts @@ -56,7 +56,7 @@ describe("readLock / writeLock", () => { test("round-trips through disk with 2-space indent and trailing newline", () => { const lock = makeLock(); writeLock(testDir, lock); - const raw = fs.readFileSync(path.join(testDir, ".github/tailor-sdk.lock"), "utf-8"); + const raw = fs.readFileSync(path.join(testDir, ".github/tailor.lock"), "utf-8"); expect(raw.endsWith("\n")).toBe(true); expect(raw).toContain(' "version": 1'); expect(readLock(testDir)).toEqual(lock); @@ -91,7 +91,7 @@ describe("readLock / writeLock", () => { }, ])("$title", ({ content, error }) => { fs.mkdirSync(path.join(testDir, ".github"), { recursive: true }); - fs.writeFileSync(path.join(testDir, ".github/tailor-sdk.lock"), content()); + fs.writeFileSync(path.join(testDir, ".github/tailor.lock"), content()); expect(() => readLock(testDir)).toThrow(error); }); }); diff --git a/packages/sdk/src/cli/commands/setup/lock.ts b/packages/sdk/src/cli/commands/setup/lock.ts index acead307f..cfdde8ed0 100644 --- a/packages/sdk/src/cli/commands/setup/lock.ts +++ b/packages/sdk/src/cli/commands/setup/lock.ts @@ -6,7 +6,7 @@ import * as path from "pathe"; export const LOCK_VERSION = 1; /** Lock file path, relative to the repository root. */ -const LOCK_FILENAME = ".github/tailor-sdk.lock"; +const LOCK_FILENAME = ".github/tailor.lock"; export type TargetKind = "branch" | "tag" | "preview" | "action" | "coordinate"; @@ -94,14 +94,14 @@ export function readLock(outputDir: string): LockFile | null { } catch (cause) { throw new Error( `${LOCK_FILENAME} is not valid JSON. The lock file is machine-owned; ` + - "restore it from git (git checkout -- .github/tailor-sdk.lock) and re-run setup.", + "restore it from git (git checkout -- .github/tailor.lock) and re-run setup.", { cause }, ); } if (typeof parsed.version !== "number") { throw new Error( `${LOCK_FILENAME} has no valid 'version' field. The lock file is machine-owned; ` + - "restore it from git (git checkout -- .github/tailor-sdk.lock) and re-run setup.", + "restore it from git (git checkout -- .github/tailor.lock) and re-run setup.", ); } if (parsed.version > LOCK_VERSION) { @@ -113,7 +113,7 @@ export function readLock(outputDir: string): LockFile | null { if (!Array.isArray(parsed.targets)) { throw new Error( `${LOCK_FILENAME} has no valid 'targets' array. The lock file is machine-owned; ` + - "restore it from git (git checkout -- .github/tailor-sdk.lock) and re-run setup.", + "restore it from git (git checkout -- .github/tailor.lock) and re-run setup.", ); } return parsed; diff --git a/packages/sdk/src/cli/commands/setup/preview.workflow.yml b/packages/sdk/src/cli/commands/setup/preview.workflow.yml index 9a30193ea..d2adfc72d 100644 --- a/packages/sdk/src/cli/commands/setup/preview.workflow.yml +++ b/packages/sdk/src/cli/commands/setup/preview.workflow.yml @@ -6,7 +6,7 @@ on: branches: ["__BRANCH__"] # __PR_TYPES__ # To deploy only for PRs that carry the `tailor:preview` label instead of all PRs, - # re-run: tailor-sdk setup --require-preview-label + # re-run: tailor setup preview --require-preview-label # __PATHS__ permissions: diff --git a/packages/sdk/src/cli/commands/setup/templates.ts b/packages/sdk/src/cli/commands/setup/templates.ts index ad00bc230..8789b8673 100644 --- a/packages/sdk/src/cli/commands/setup/templates.ts +++ b/packages/sdk/src/cli/commands/setup/templates.ts @@ -13,12 +13,12 @@ export const TEMPLATE_VERSION = 7; export type PackageManager = "pnpm" | "yarn" | "npm" | "bun"; -const HEADER = `# Generated by \`tailor-sdk setup\` — managed by the Tailor SDK. +const HEADER = `# Generated by \`tailor setup\` — managed by the Tailor SDK. # # - Jobs and steps whose id starts with \`tailor-\` are managed by the SDK. # Do not edit or rename them. -# - State is tracked in .github/tailor-sdk.lock (machine-owned: commit it, never edit it). -# - Re-running \`tailor-sdk setup\` regenerates this file. If you have +# - State is tracked in .github/tailor.lock (machine-owned: commit it, never edit it). +# - Re-running \`tailor setup\` regenerates this file. If you have # edited it by hand, regeneration stops and asks for --force (which discards # your edits), so prefer keeping customizations in your own jobs/steps and # re-running setup after SDK updates.`; @@ -660,10 +660,10 @@ export function renderCoordinateWorkflow(params: RenderCoordinateParams): Render export function renderTailorSetupAction(params: { packageManager: PackageManager }): string { const { packageManager } = params; return [ - `# This file is user-owned. Generated once by \`tailor-sdk setup coordinate\` and never overwritten.`, + `# This file is user-owned. Generated once by \`tailor setup coordinate\` and never overwritten.`, `# Customize freely: add pre/post steps, change install flags, etc.`, `name: Tailor Setup`, - `description: Install dependencies and run tailor-sdk setup for composite action callers.`, + `description: Install dependencies and run tailor setup for composite action callers.`, `runs:`, ` using: composite`, ` steps:`, 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 e129190c5..d3708847b 100644 --- a/packages/sdk/src/cli/commands/setup/workflow-lint.test.ts +++ b/packages/sdk/src/cli/commands/setup/workflow-lint.test.ts @@ -104,7 +104,7 @@ describe("repository ERD schema workflow", () => { test("exports on push to main, uploading a per-namespace artifact for the preview job to reuse", () => { const content = fs.readFileSync(ERD_SCHEMA_WORKFLOW, "utf-8"); - expect(content).toContain("branches: [main]"); + expect(content).toContain("branches: [main, v2]"); expect(content).toContain("artifact-name: erd-schema-${{ matrix.namespace }}"); expect(content).toContain('retention-days: "90"'); }); @@ -124,7 +124,7 @@ describe("repository ERD schema workflow", () => { const content = fs.readFileSync(ERD_SCHEMA_WORKFLOW, "utf-8"); expect(content).toContain("example/tailor.config.ts"); - expect(content).toContain("packages/sdk/src/cli/commands/tailordb/erd/"); + expect(content).toContain("packages/sdk-plugin-tailordb-erd/"); expect(content).toContain("relevant-path-prefix: example/"); }); diff --git a/packages/sdk/src/cli/commands/show.ts b/packages/sdk/src/cli/commands/show.ts index f0ac0d10f..c2c972de8 100644 --- a/packages/sdk/src/cli/commands/show.ts +++ b/packages/sdk/src/cli/commands/show.ts @@ -136,11 +136,9 @@ const showWorkspaceNameTransformer = createWorkspaceNameTransformer( export const showCommand = defineAppCommand({ name: "show", description: "Show information about the deployed application.", - args: z - .object({ - ...deploymentArgs, - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + }), run: async (args) => { // Execute show logic const appInfo = await show({ diff --git a/packages/sdk/src/cli/commands/skills/index.ts b/packages/sdk/src/cli/commands/skills/index.ts deleted file mode 100644 index 9d4821c80..000000000 --- a/packages/sdk/src/cli/commands/skills/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineCommand, runCommand } from "politty"; -import { installCommand } from "./install"; - -export const skillsCommand = defineCommand({ - name: "skills", - description: "Manage Tailor SDK agent skills.", - subCommands: { - install: installCommand, - }, - async run() { - await runCommand(installCommand, []); - }, -}); diff --git a/packages/sdk/src/cli/commands/skills/install.test.ts b/packages/sdk/src/cli/commands/skills/install.test.ts deleted file mode 100644 index 3cd3901ca..000000000 --- a/packages/sdk/src/cli/commands/skills/install.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { existsSync, statSync } from "node:fs"; -import { resolve } from "pathe"; -import { describe, expect, test } from "vitest"; -import { installCommand, resolveBundledSkillsDir } from "./install"; - -describe("resolveBundledSkillsDir", () => { - test("resolves to the SDK package's agent-skills/ directory", async () => { - const dir = await resolveBundledSkillsDir(); - expect(dir.endsWith("/agent-skills")).toBe(true); - expect(existsSync(dir)).toBe(true); - expect(statSync(dir).isDirectory()).toBe(true); - expect(existsSync(resolve(dir, "tailor-sdk", "SKILL.md"))).toBe(true); - }); -}); - -describe("installCommand args", () => { - test("defaults agent to 'claude-code' (vercel/skills' canonical name)", () => { - const parsed = installCommand.args.parse({}); - expect(parsed.agent).toBe("claude-code"); - }); -}); diff --git a/packages/sdk/src/cli/commands/skills/install.ts b/packages/sdk/src/cli/commands/skills/install.ts deleted file mode 100644 index 46f56a16d..000000000 --- a/packages/sdk/src/cli/commands/skills/install.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { dirname, resolve } from "pathe"; -import { resolvePackageJSON } from "pkg-types"; -import { arg } from "politty"; -import { z } from "zod"; -import { defineAppCommand } from "#/cli/shared/command"; -import { runSkillsInstaller } from "#/cli/shared/skills-installer"; - -// Resolve the SDK package root at runtime so the skills directory is found -// regardless of how the file is bundled (tsdown inlines non-entry modules). -// The directory is named `agent-skills` (not `skills`) to avoid `gh skill`'s -// `**/skills/*/SKILL.md` recursive match that would otherwise pick up both -// this bundled copy and the repo-root `skills/` and report a conflict. -export async function resolveBundledSkillsDir(): Promise { - const pkgJsonPath = await resolvePackageJSON(import.meta.url); - return resolve(dirname(pkgJsonPath), "agent-skills"); -} - -const DEFAULT_AGENT = "claude-code"; - -export const installCommand = defineAppCommand({ - name: "install", - description: "Install the tailor-sdk agent skill from the installed SDK package.", - args: z - .object({ - agent: arg(z.string().default(DEFAULT_AGENT), { - alias: "a", - description: `vercel/skills agent name (e.g. ${DEFAULT_AGENT}, codex). Defaults to ${DEFAULT_AGENT}.`, - }), - yes: arg(z.boolean().default(false), { - alias: "y", - description: "Auto-approve prompts.", - }), - }) - .strict(), - run: async (args) => { - const exitCode = await runSkillsInstaller({ - source: await resolveBundledSkillsDir(), - agent: args.agent, - yes: args.yes, - }); - if (exitCode !== 0) { - process.exit(exitCode); - } - }, -}); diff --git a/packages/sdk/src/cli/commands/staticwebsite/deploy.ts b/packages/sdk/src/cli/commands/staticwebsite/deploy.ts index 0514a9aa0..c737d85a6 100644 --- a/packages/sdk/src/cli/commands/staticwebsite/deploy.ts +++ b/packages/sdk/src/cli/commands/staticwebsite/deploy.ts @@ -229,20 +229,18 @@ export function logSkippedFiles(skippedFiles: string[]) { export const deployCommand = defineAppCommand({ name: "deploy", description: "Deploy a static website from a local build directory.", - args: z - .object({ - ...workspaceArgs, - name: arg(z.string(), { - alias: "n", - description: "Static website name", - }), - dir: arg(z.string(), { - alias: "d", - description: "Path to the static website files", - completion: { type: "directory" }, - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + name: arg(z.string(), { + alias: "n", + description: "Static website name", + }), + dir: arg(z.string(), { + alias: "d", + description: "Path to the static website files", + completion: { type: "directory" }, + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); logger.info(`Deploying static website "${args.name}" from directory: ${args.dir}`); diff --git a/packages/sdk/src/cli/commands/staticwebsite/domain/get.ts b/packages/sdk/src/cli/commands/staticwebsite/domain/get.ts index c03c83293..3ad530bec 100644 --- a/packages/sdk/src/cli/commands/staticwebsite/domain/get.ts +++ b/packages/sdk/src/cli/commands/staticwebsite/domain/get.ts @@ -11,15 +11,13 @@ import { statusLabels } from "./status"; export const domainGetCommand = defineAppCommand({ name: "get", description: "Get details of a custom domain.", - args: z - .object({ - ...workspaceArgs, - domain: arg(z.string(), { - positional: true, - description: "Custom domain name", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + domain: arg(z.string(), { + positional: true, + description: "Custom domain name", + }), + }), run: async (args) => { const accessToken = await loadAccessToken({ profile: args.profile, diff --git a/packages/sdk/src/cli/commands/staticwebsite/domain/list.ts b/packages/sdk/src/cli/commands/staticwebsite/domain/list.ts index d9a6cfbed..fe05ba0f5 100644 --- a/packages/sdk/src/cli/commands/staticwebsite/domain/list.ts +++ b/packages/sdk/src/cli/commands/staticwebsite/domain/list.ts @@ -11,15 +11,13 @@ import { statusLabels } from "./status"; export const domainListCommand = defineAppCommand({ name: "list", description: "List custom domains for a static website.", - args: z - .object({ - ...workspaceArgs, - name: arg(z.string(), { - positional: true, - description: "Static website name", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + name: arg(z.string(), { + positional: true, + description: "Static website name", + }), + }), run: async (args) => { const accessToken = await loadAccessToken({ profile: args.profile, diff --git a/packages/sdk/src/cli/commands/staticwebsite/get.ts b/packages/sdk/src/cli/commands/staticwebsite/get.ts index 537ccc547..0d451d34c 100644 --- a/packages/sdk/src/cli/commands/staticwebsite/get.ts +++ b/packages/sdk/src/cli/commands/staticwebsite/get.ts @@ -10,15 +10,13 @@ import { logger } from "#/cli/shared/logger"; export const getCommand = defineAppCommand({ name: "get", description: "Get details of a specific static website.", - args: z - .object({ - ...workspaceArgs, - name: arg(z.string(), { - positional: true, - description: "Static website name", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + name: arg(z.string(), { + positional: true, + description: "Static website name", + }), + }), run: async (args) => { const accessToken = await loadAccessToken({ profile: args.profile, diff --git a/packages/sdk/src/cli/commands/staticwebsite/list.ts b/packages/sdk/src/cli/commands/staticwebsite/list.ts index 0fe4d56f0..b22df94f9 100644 --- a/packages/sdk/src/cli/commands/staticwebsite/list.ts +++ b/packages/sdk/src/cli/commands/staticwebsite/list.ts @@ -63,12 +63,10 @@ async function listStaticWebsites( export const listCommand = defineAppCommand({ name: "list", description: "List all static websites in a workspace.", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const jsonOutput = logger.jsonMode; const websites = await listStaticWebsites({ diff --git a/packages/sdk/src/cli/commands/tailordb/erd/index.ts b/packages/sdk/src/cli/commands/tailordb/erd/index.ts deleted file mode 100644 index 95f1f8ef8..000000000 --- a/packages/sdk/src/cli/commands/tailordb/erd/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineCommand } from "politty"; -import { erdDeployCommand } from "./deploy"; -import { erdDiffCommand } from "./diff-command"; -import { erdExportCommand } from "./export"; -import { erdServeCommand } from "./serve"; - -export const erdCommand = defineCommand({ - name: "erd", - description: "Generate TailorDB ERD viewer artifacts from local TailorDB schema. (beta)", - subCommands: { - export: erdExportCommand, - diff: erdDiffCommand, - serve: erdServeCommand, - deploy: erdDeployCommand, - }, -}); diff --git a/packages/sdk/src/cli/commands/tailordb/erd/local-schema.ts b/packages/sdk/src/cli/commands/tailordb/erd/local-schema.ts deleted file mode 100644 index 6f2555172..000000000 --- a/packages/sdk/src/cli/commands/tailordb/erd/local-schema.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { defineApplication } from "#/cli/services/application"; -import { loadConfig } from "#/cli/shared/config-loader"; -import { generateUserTypes } from "#/cli/shared/type-generator"; -import { PluginManager } from "#/plugin/manager"; -import type { LoadedConfig } from "#/cli/shared/config-loader"; -import type { TailorDBNamespaceData } from "#/plugin/types"; - -export interface LoadLocalErdSchemaOptions { - configPath?: string; - namespaces?: string[]; - requireErdSite?: boolean; -} - -export interface LocalErdSchemaContext { - config: LoadedConfig; - namespaces: TailorDBNamespaceData[]; -} - -export interface ResolveLocalErdSchemaNamespacesOptions { - /** Explicit namespace selection. */ - namespaces?: string[]; - /** Limit implicit selection to owned namespaces with erdSite configured. */ - requireErdSite?: boolean; -} - -/** - * Resolve TailorDB namespaces that need local type loading for ERD generation. - * @param config - Loaded Tailor config. - * @param options - Namespace selection options. - * @returns Namespace names to load, or undefined to load all owned namespaces. - */ -export function resolveLocalErdSchemaNamespaces( - config: LoadedConfig, - options: ResolveLocalErdSchemaNamespacesOptions, -): string[] | undefined { - if (options.namespaces) { - return options.namespaces; - } - if (!options.requireErdSite) { - return undefined; - } - - return Object.entries(config.db ?? {}).flatMap(([namespace, dbConfig]) => - "external" in dbConfig || !dbConfig.erdSite ? [] : [namespace], - ); -} - -/** - * Load local TailorDB namespaces exactly as SDK generation/deploy sees them. - * @param options - Local schema loading options. - * @returns Loaded TailorDB namespace data. - */ -export async function loadLocalErdSchema( - options: LoadLocalErdSchemaOptions, -): Promise { - const { config, plugins } = await loadConfig(options.configPath); - - await generateUserTypes({ config, configPath: config.path }); - - const pluginManager = plugins.length > 0 ? new PluginManager(plugins) : undefined; - const application = defineApplication({ - config, - pluginManager, - }); - const namespaceNames = resolveLocalErdSchemaNamespaces(config, { - namespaces: options.namespaces, - requireErdSite: options.requireErdSite, - }); - const namespaceFilter = namespaceNames ? new Set(namespaceNames) : undefined; - const services = namespaceFilter - ? application.tailorDBServices.filter((db) => namespaceFilter.has(db.namespace)) - : application.tailorDBServices; - - if (namespaceFilter && services.length !== namespaceFilter.size) { - const available = application.tailorDBServices.map((db) => db.namespace).join(", "); - const requested = [...namespaceFilter].join(", "); - throw new Error( - `TailorDB namespace "${requested}" not found in local config.db.` + - (available ? ` Available owned namespaces: ${available}` : ""), - ); - } - - const namespaces: TailorDBNamespaceData[] = []; - - for (const db of services) { - await db.loadTypes(); - await db.processNamespacePlugins(); - namespaces.push({ - namespace: db.namespace, - types: { ...db.types }, - sourceInfo: new Map(Object.entries(db.typeSourceInfo)), - pluginAttachments: db.pluginAttachments, - }); - } - - return { config, namespaces }; -} diff --git a/packages/sdk/src/cli/commands/tailordb/index.ts b/packages/sdk/src/cli/commands/tailordb/index.ts index 43de0d14e..333bcabd6 100644 --- a/packages/sdk/src/cli/commands/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/tailordb/index.ts @@ -1,14 +1,14 @@ import { defineCommand } from "politty"; -import { erdCommand } from "./erd"; import { migrationCommand } from "./migrate"; import { truncateCommand } from "./truncate"; export const tailordbCommand = defineCommand({ name: "tailordb", description: "Manage TailorDB tables and data.", + notes: + "The `tailordb erd` commands are provided by the @tailor-platform/sdk-plugin-tailordb-erd CLI plugin.", subCommands: { truncate: truncateCommand, migration: migrationCommand, - erd: erdCommand, }, }); diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/bundler.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/bundler.test.ts index c7188a43f..69f99a0e8 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/bundler.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/bundler.test.ts @@ -16,13 +16,13 @@ describe("migration-bundler", () => { `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ); fs.mkdirSync(testDir, { recursive: true }); - // Set TAILOR_SDK_OUTPUT_DIR to testDir so bundled output goes into test directory - process.env.TAILOR_SDK_OUTPUT_DIR = testDir; + // Set TAILOR_BUILD_OUTPUT_DIR to testDir so bundled output goes into test directory + process.env.TAILOR_BUILD_OUTPUT_DIR = testDir; }); afterAll(() => { // Clean up environment variable - delete process.env.TAILOR_SDK_OUTPUT_DIR; + delete process.env.TAILOR_BUILD_OUTPUT_DIR; try { fs.rmSync(TEST_BUNDLER_BASE, { recursive: true, force: true }); } catch { diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/bundler.ts b/packages/sdk/src/cli/commands/tailordb/migrate/bundler.ts index 8f388b5fa..402a4af35 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/bundler.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/bundler.ts @@ -38,7 +38,7 @@ export async function bundleMigrationScript( migrationNumber: number, env: Record = {}, ): Promise { - // Output directory in .tailor-sdk (relative to project root) + // Output directory in .tailor (relative to project root) const outputDir = path.resolve(getDistDir(), "migrations"); fs.mkdirSync(outputDir, { recursive: true }); diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts index f928b9882..e470df5b9 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts @@ -147,7 +147,7 @@ describe("db-types-generator", () => { expectedContains: ["externalId: string;", "referenceId: string | null;"], }, { - testName: "generates types with date/datetime fields using Timestamp", + testName: "generates types with date strings and datetime Timestamps", typeName: "Event", fields: { eventDate: { type: "date", required: true }, @@ -156,7 +156,7 @@ describe("db-types-generator", () => { }, expectedContains: [ "type Timestamp = ColumnType;", - "eventDate: Timestamp;", + "eventDate: string;", "startTime: Timestamp;", "endTime: Timestamp | null;", ], @@ -304,6 +304,68 @@ describe("db-types-generator", () => { expect(content).toContain("email: ColumnType;"); }); + test("generates ColumnType for optional to required datetime change", async () => { + const snapshot = createMockSnapshot({ + User: { + fields: { + updatedAt: { type: "datetime", required: true }, + }, + }, + }); + createMigrationDir(testDir, 1); + + const diff = createMockMigrationDiff({ + changes: [ + { + kind: "field_modified", + typeName: "User", + fieldName: "updatedAt", + before: { type: "datetime", required: false }, + after: { type: "datetime", required: true }, + }, + ], + hasBreakingChanges: true, + requiresMigrationScript: true, + }); + + const filePath = await writeDbTypesFile(snapshot, testDir, 1, diff); + const content = fs.readFileSync(filePath, "utf-8"); + + expect(content).toContain( + "updatedAt: ColumnType;", + ); + }); + + test("generates ColumnType for optional to required date change", async () => { + const snapshot = createMockSnapshot({ + User: { + fields: { + birthDate: { type: "date", required: true }, + }, + }, + }); + createMigrationDir(testDir, 1); + + const diff = createMockMigrationDiff({ + changes: [ + { + kind: "field_modified", + typeName: "User", + fieldName: "birthDate", + before: { type: "date", required: false }, + after: { type: "date", required: true }, + }, + ], + hasBreakingChanges: true, + requiresMigrationScript: true, + }); + + const filePath = await writeDbTypesFile(snapshot, testDir, 1, diff); + const content = fs.readFileSync(filePath, "utf-8"); + + expect(content).toContain("birthDate: ColumnType;"); + }); + test("generates ColumnType for added required fields", async () => { const snapshot = createMockSnapshot({ User: { diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts index 67cc52eef..baa254fad 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts @@ -291,6 +291,7 @@ function mapToTsType(fieldType: string): { case "number": return { type: "number", usedTimestamp: false }; case "date": + return { type: "string", usedTimestamp: false }; case "datetime": return { type: "Timestamp", usedTimestamp: true }; case "bool": @@ -325,6 +326,24 @@ function generateEnumChangeColumnType( return `ColumnType<${selectType}, ${afterType}, ${afterType}>`; } +function generateOptionalToRequiredDateColumnType(config: SnapshotFieldConfig): string | null { + if (config.type !== "date" && config.type !== "datetime") return null; + + if (config.type === "date") { + if (config.array) { + return "ColumnType"; + } + + return "ColumnType"; + } + + if (config.array) { + return "ColumnType"; + } + + return "ColumnType"; +} + /** * Generate field type from snapshot field config * @param {SnapshotFieldConfig} config - Field configuration @@ -373,6 +392,15 @@ function generateFieldType( // Handle nullable/required modifiers if (isOptionalToRequired) { + const dateColumnType = generateOptionalToRequiredDateColumnType(config); + if (dateColumnType) { + return { + type: dateColumnType, + usedTimestamp: false, + usedColumnType: true, + }; + } + // For fields changing from optional to required: // SELECT returns T | null (existing data might be null) // INSERT/UPDATE requires T (must provide a value) diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/diff-calculator.ts b/packages/sdk/src/cli/commands/tailordb/migrate/diff-calculator.ts index 47f7ab40a..45de3199e 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/diff-calculator.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/diff-calculator.ts @@ -197,6 +197,19 @@ export interface PermissionModifiedChange extends DiffChangeBase { after?: SnapshotPermissionState; } +/** Type-level hook/validate script state for diff tracking. */ +export interface TypeScriptsState { + typeHookExpr?: { create?: string; update?: string }; + typeValidateExpr?: string; +} + +/** Type-level hook/validate scripts changed. */ +export interface TypeScriptsModifiedChange extends DiffChangeBase { + kind: "type_scripts_modified"; + before: TypeScriptsState; + after: TypeScriptsState; +} + /** * Single change in migration diff, discriminated by `kind` so that * `before`/`after` are typed per change kind. @@ -218,7 +231,8 @@ export type DiffChange = | RelationshipAddedChange | RelationshipRemovedChange | RelationshipModifiedChange - | PermissionModifiedChange; + | PermissionModifiedChange + | TypeScriptsModifiedChange; /** * Field-level diff change (added / removed / modified). @@ -358,6 +372,8 @@ function formatDiffChange(change: DiffChange): string { return ` ~ [Relationship${change.relationshipType ? ` (${change.relationshipType})` : ""}] ${change.relationshipName}: ${change.reason ?? "modified"}`; case "permission_modified": return ` ~ [Permission] ${change.reason ?? "modified"}`; + case "type_scripts_modified": + return ` ~ [Type Scripts] ${change.typeName}: ${change.reason ?? "type-level hooks/validate changed"}`; default: { // Runtime fallback: diff.json is parsed without validation, so // hand-edited or future-version files may carry unknown kinds. @@ -502,6 +518,7 @@ const DIFF_CHANGE_LABELS: Record = { relationship_removed: "relationship(s) removed", relationship_modified: "relationship(s) modified", permission_modified: "permission(s) modified", + type_scripts_modified: "type script(s) modified", }; /** diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/generate.ts b/packages/sdk/src/cli/commands/tailordb/migrate/generate.ts index 769500a29..789efdf18 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/generate.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/generate.ts @@ -303,7 +303,7 @@ async function generateDiffFromSnapshot( } logger.newline(); logger.log("A migration script was generated for breaking changes."); - logger.log("Please review and edit the script before running 'tailor-sdk deploy'."); + logger.log("Please review and edit the script before running 'tailor deploy'."); const editor = getConfiguredEditorCommand(); if (!editor) { @@ -330,7 +330,7 @@ async function generateDiffFromSnapshot( `Data loss is possible for this migration but no script was generated. To add a custom migrate.ts, run:`, ); logger.log( - ` ${styles.bold(`tailor-sdk tailordb migration script ${result.migrationNumber.toString().padStart(4, "0")} --namespace ${diff.namespace}`)}`, + ` ${styles.bold(`tailor tailordb migration script ${result.migrationNumber.toString().padStart(4, "0")} --namespace ${diff.namespace}`)}`, ); } } @@ -342,19 +342,17 @@ export const generateCommand = defineAppCommand({ name: "generate", description: "Generate migration files by detecting schema differences between current local types and the previous migration snapshot.", - args: z - .object({ - ...confirmationArgs, - ...configArg, - name: arg(z.string().optional(), { - alias: "n", - description: "Optional description for the migration", - }), - init: arg(z.boolean().default(false), { - description: "Delete existing migrations and start fresh", - }), - }) - .strict(), + args: z.strictObject({ + ...confirmationArgs, + ...configArg, + name: arg(z.string().optional(), { + alias: "n", + description: "Optional description for the migration", + }), + init: arg(z.boolean().default(false), { + description: "Delete existing migrations and start fresh", + }), + }), run: async (args) => { await generate({ configPath: args.config, diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/script.ts b/packages/sdk/src/cli/commands/tailordb/migrate/script.ts index 86095ec96..12b974e8d 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/script.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/script.ts @@ -100,7 +100,7 @@ async function script(options: ScriptOptions): Promise { logger.newline(); logger.log("Edit the script to implement your data migration logic."); - logger.log("It will be executed by 'tailor-sdk deploy' between Pre and Post phases."); + logger.log("It will be executed by 'tailor deploy' between Pre and Post phases."); const editor = getConfiguredEditorCommand(); if (!editor) return; @@ -136,19 +136,17 @@ function resolveTargetNamespace( export const scriptCommand = defineAppCommand({ name: "script", description: "Add a migration script (migrate.ts) template to an existing migration directory.", - args: z - .object({ - ...configArg, - number: arg(z.string(), { - positional: true, - description: "Migration number to add a script to (e.g., 0001 or 1)", - }), - namespace: arg(z.string().optional(), { - alias: "n", - description: "Target TailorDB namespace (required if multiple namespaces exist)", - }), - }) - .strict(), + args: z.strictObject({ + ...configArg, + number: arg(z.string(), { + positional: true, + description: "Migration number to add a script to (e.g., 0001 or 1)", + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "Target TailorDB namespace (required if multiple namespaces exist)", + }), + }), run: async (args) => { await script({ configPath: args.config, diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/set.ts b/packages/sdk/src/cli/commands/tailordb/migrate/set.ts index 3538881ee..9596ab8f0 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/set.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/set.ts @@ -154,20 +154,18 @@ async function set(options: SetOptions): Promise { export const setCommand = defineAppCommand({ name: "set", description: "Set migration checkpoint to a specific number.", - args: z - .object({ - ...deploymentArgs, - ...confirmationArgs, - number: arg(z.string(), { - positional: true, - description: "Migration number to set (e.g., 0001 or 1)", - }), - namespace: arg(z.string().optional(), { - alias: "n", - description: "Target TailorDB namespace (required if multiple namespaces exist)", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...confirmationArgs, + number: arg(z.string(), { + positional: true, + description: "Migration number to set (e.g., 0001 or 1)", + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "Target TailorDB namespace (required if multiple namespaces exist)", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); await set({ diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts index 304ffedfe..f06cce271 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts @@ -294,7 +294,7 @@ describe("snapshot-manifest", () => { expect(manifest.schema?.settings?.disableGqlOperations?.delete).toBe(true); }); - test("handles hooks configuration", () => { + test("aggregates field hooks into a type-level hook script", () => { const snapshotType = createTestSnapshotType("User", { fields: { id: { type: "uuid", required: true }, @@ -311,11 +311,19 @@ describe("snapshot-manifest", () => { const manifest = generateTailorDBTypeManifestFromSnapshot(snapshotType); - expect(manifest.schema?.fields?.updatedAt?.hooks?.create?.expr).toBe("now()"); - expect(manifest.schema?.fields?.updatedAt?.hooks?.update?.expr).toBe("now()"); + expect(manifest.schema?.fields?.updatedAt?.optionalOnCreate).toBe(true); + expect(manifest.schema?.fields?.updatedAt?.hooks).toBeUndefined(); + + // They are aggregated into a single type-level script that binds a shared + // timestamp once and dispatches each field's hook. + const createHook = manifest.schema?.typeHook?.create?.expr ?? ""; + expect(createHook).toContain("const _now = new Date()"); + expect(createHook).toContain('"updatedAt": ((_value) => (now()))(_input["updatedAt"])'); + expect(manifest.schema?.typeHook?.update?.expr).toContain('_input["updatedAt"]'); + expect(manifest.schema?.typeValidate).toBeUndefined(); }); - test("keeps validate and hooks in nested fields", () => { + test("aggregates nested field hooks and validators into type-level scripts", () => { const snapshotType = createTestSnapshotType("User", { fields: { id: { type: "uuid", required: true }, @@ -366,16 +374,51 @@ describe("snapshot-manifest", () => { const displayNameField = profileField?.fields?.displayName; const emailField = profileField?.fields?.contact?.fields?.email; - expect(displayNameField?.validate).toHaveLength(1); - expect(displayNameField?.validate?.[0]?.errorMessage).toBe("Display name is required"); - expect(displayNameField?.validate?.[0]?.script?.expr).toBe("!((_value ?? '').length > 0)"); - expect(displayNameField?.hooks?.create?.expr).toBe("(_value ?? '').trim()"); - expect(displayNameField?.hooks?.update?.expr).toBe("(_value ?? '').trim()"); + expect(displayNameField?.optionalOnCreate).toBe(true); + expect(displayNameField?.hooks).toBeUndefined(); + expect(displayNameField?.validate ?? []).toHaveLength(0); + expect(emailField?.optionalOnCreate).toBe(true); + expect(emailField?.hooks).toBeUndefined(); + expect(emailField?.validate ?? []).toHaveLength(0); + + // Hooks are aggregated into a type-level script that reconstructs nested + // objects so unhooked siblings are preserved. + const hookExpr = manifest.schema?.typeHook?.create?.expr ?? ""; + expect(hookExpr).toContain('"profile": Object.assign({}, _input["profile"], {'); + expect(hookExpr).toContain("(_value ?? '').trim()"); + expect(hookExpr).toContain('(_input["profile"] || {})["displayName"]'); + expect(hookExpr).toContain( + '"contact": Object.assign({}, (_input["profile"] || {})["contact"], {', + ); + expect(hookExpr).toContain("(_value ?? '').toLowerCase()"); + + // Validators are aggregated into a type-level validate script using ?? chain. + const validateExpr = manifest.schema?.typeValidate?.create?.expr ?? ""; + expect(validateExpr).toContain('__errs["profile.displayName"]'); + expect(validateExpr).toContain("((_value ?? '').length > 0)"); + expect(validateExpr).toContain('if (typeof __r === "string")'); + expect(validateExpr).toContain('__errs["profile.contact.email"]'); + expect(manifest.schema?.typeValidate?.update?.expr).toBe(validateExpr); + }); - expect(emailField?.validate).toHaveLength(1); - expect(emailField?.validate?.[0]?.errorMessage).toBe("Email must contain @"); - expect(emailField?.validate?.[0]?.script?.expr).toBe("!((_value ?? '').includes('@'))"); - expect(emailField?.hooks?.create?.expr).toBe("(_value ?? '').toLowerCase()"); + test("type-level create hook does not make required fields optionalOnCreate", () => { + const snapshotType = createTestSnapshotType("Customer", { + fields: { + id: { type: "uuid", required: true }, + name: { type: "string", required: true }, + fullAddress: { type: "string", required: true }, + phone: { type: "string", required: false }, + }, + typeHookExpr: { + create: + '((_input, _oldRecord, _invoker, _now) => ({ fullAddress: "computed" }))(_input, null, _invoker, _now)', + }, + }); + + const manifest = generateTailorDBTypeManifestFromSnapshot(snapshotType); + expect(manifest.schema?.fields?.name?.optionalOnCreate).toBeUndefined(); + expect(manifest.schema?.fields?.fullAddress?.optionalOnCreate).toBeUndefined(); + expect(manifest.schema?.fields?.phone?.optionalOnCreate).toBeUndefined(); }); test("handles serial configuration", () => { diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts index af6b8b36c..637922b4b 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts @@ -18,7 +18,6 @@ import { type TailorDBGQLPermissionSchema, TailorDBType_Permission_Operator, TailorDBType_Permission_Permit, - TailorDBType_PermitAction, type TailorDBType_FieldConfigSchema, type TailorDBType_FileConfigSchema, type TailorDBType_IndexSchema, @@ -30,6 +29,7 @@ import { type TailorDBTypeSchema, } from "@tailor-platform/tailor-proto/tailordb_resource_pb"; import * as inflection from "inflection"; +import { buildTypeScripts } from "#/parser/service/tailordb/type-script"; import { isSnapshotFieldRefOperand } from "./snapshot"; import type { SchemaSnapshot, @@ -115,7 +115,8 @@ export function generateTailorDBTypeManifestFromSnapshot( const fields: Record> = {}; for (const [fieldName, fieldConfig] of Object.entries(snapshotType.fields)) { if (fieldName === "id") continue; - fields[fieldName] = convertFieldConfigToProto(fieldConfig); + const fieldProto = convertFieldConfigToProto(fieldConfig); + fields[fieldName] = fieldProto; } // Build relationships @@ -163,6 +164,13 @@ export function generateTailorDBTypeManifestFromSnapshot( ? convertRecordPermissionToProto(snapshotType.permissions.record) : defaultPermission; + // Field hooks/validators are aggregated into type-level scripts so that a + // single shared timestamp is observed across every field in one operation. + const { typeHook, typeValidate } = buildTypeScripts(snapshotType.fields, { + typeHookExpr: snapshotType.typeHookExpr, + typeValidateExpr: snapshotType.typeValidateExpr, + }); + return { name: snapshotType.name, schema: { @@ -175,10 +183,18 @@ export function generateTailorDBTypeManifestFromSnapshot( indexes, files, permission, + ...(typeHook && { typeHook }), + ...(typeValidate && { typeValidate }), }, }; } +function optionalOnCreate( + config: Pick, +): Pick, "optionalOnCreate"> { + return config.hooks?.create || config.default !== undefined ? { optionalOnCreate: true } : {}; +} + /** * Convert a snapshot field config to proto format * @param {SnapshotFieldConfig} config - Snapshot field config @@ -194,7 +210,6 @@ export function convertFieldConfigToProto( ? (config.allowedValues?.map((v: SnapshotEnumValue) => ({ ...v })) ?? []) : [], description: config.description || "", - validate: toProtoSnapshotFieldValidate(config), array: config.array ?? false, index: config.index ?? false, unique: config.unique ?? false, @@ -203,7 +218,7 @@ export function convertFieldConfigToProto( foreignKeyField: config.foreignKeyField, required: config.required, vector: config.vector ?? false, - ...toProtoSnapshotFieldHooks(config), + ...optionalOnCreate(config), ...(config.serial && { serial: { start: BigInt(config.serial.start), @@ -226,40 +241,6 @@ export function convertFieldConfigToProto( return fieldEntry; } -function toProtoSnapshotFieldValidate( - config: SnapshotFieldConfig, -): MessageInitShape["validate"] { - return (config.validate ?? []).map((val) => ({ - action: TailorDBType_PermitAction.DENY, - errorMessage: val.errorMessage || "", - script: { - expr: val.script && val.script.expr ? `!${val.script.expr}` : "", - }, - })); -} - -function toProtoSnapshotFieldHooks( - config: SnapshotFieldConfig, -): Pick, "hooks"> | Record { - if (!config.hooks) { - return {}; - } - return { - hooks: { - create: config.hooks.create - ? { - expr: config.hooks.create.expr || "", - } - : undefined, - update: config.hooks.update - ? { - expr: config.hooks.update.expr || "", - } - : undefined, - }, - }; -} - /** * Process nested fields from snapshot format to proto format * @param {Record} fields - Nested fields @@ -277,14 +258,12 @@ function processNestedFieldsFromSnapshot( type: "nested", allowedValues: fieldConfig.allowedValues?.map((v: SnapshotEnumValue) => ({ ...v })) ?? [], description: fieldConfig.description || "", - validate: toProtoSnapshotFieldValidate(fieldConfig), required: fieldConfig.required, array: fieldConfig.array ?? false, index: false, unique: false, foreignKey: false, vector: false, - ...toProtoSnapshotFieldHooks(fieldConfig), fields: deepNestedFields, ...(fieldConfig.scale !== undefined && { scale: fieldConfig.scale }), }; @@ -296,14 +275,13 @@ function processNestedFieldsFromSnapshot( ? (fieldConfig.allowedValues?.map((v: SnapshotEnumValue) => ({ ...v })) ?? []) : [], description: fieldConfig.description || "", - validate: toProtoSnapshotFieldValidate(fieldConfig), required: fieldConfig.required, array: fieldConfig.array ?? false, index: false, unique: false, foreignKey: false, vector: false, - ...toProtoSnapshotFieldHooks(fieldConfig), + ...optionalOnCreate(fieldConfig), ...(fieldConfig.serial && { serial: { start: BigInt(fieldConfig.serial.start), diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-schema.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-schema.ts index 5820180c5..d39424b9e 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-schema.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-schema.ts @@ -31,6 +31,8 @@ import type { RelationshipRemovedChange, RelationshipModifiedChange, PermissionModifiedChange, + TypeScriptsModifiedChange, + TypeScriptsState, DiffChange, BreakingChangeInfo, WarningChangeInfo, @@ -124,6 +126,7 @@ export const snapshotFieldConfigSchema: z.ZodType = z.loose validate: z.array(snapshotValidationSchema).optional(), serial: snapshotSerialSchema.optional(), scale: z.number().optional(), + default: z.unknown().optional(), fields: z.lazy(() => snapshotRecordSchema(snapshotFieldConfigSchema)).optional(), }) as z.ZodType; @@ -446,6 +449,24 @@ const permissionModifiedChangeSchema = z.looseObject({ after: snapshotPermissionStateSchema.optional(), }) as unknown as z.ZodType; +const typeScriptsStateSchema: z.ZodType = z.looseObject({ + typeHookExpr: z + .looseObject({ + create: z.string().optional(), + update: z.string().optional(), + }) + .optional(), + typeValidateExpr: z.string().optional(), +}); + +const typeScriptsModifiedChangeSchema = z.looseObject({ + kind: z.literal("type_scripts_modified"), + typeName: z.string(), + reason: z.string().optional(), + before: typeScriptsStateSchema, + after: typeScriptsStateSchema, +}) as unknown as z.ZodType; + type DiscriminableSchema = Parameters[1][number]; export const diffChangeSchema: z.ZodType = z.discriminatedUnion("kind", [ @@ -466,6 +487,7 @@ export const diffChangeSchema: z.ZodType = z.discriminatedUnion("kin relationshipRemovedChangeSchema as unknown as DiscriminableSchema, relationshipModifiedChangeSchema as unknown as DiscriminableSchema, permissionModifiedChangeSchema as unknown as DiscriminableSchema, + typeScriptsModifiedChangeSchema as unknown as DiscriminableSchema, ]) as z.ZodType; export const breakingChangeInfoSchema: z.ZodType = z.looseObject({ diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-types.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-types.ts index 50a2576a6..109be51d8 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-types.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-types.ts @@ -65,6 +65,7 @@ export interface SnapshotFieldConfig { validate?: SnapshotValidation[]; serial?: SnapshotSerial; scale?: number; + default?: unknown; /** Nested fields (recursive) */ fields?: Record; } @@ -218,6 +219,8 @@ export interface TailorDBSnapshotType { record?: SnapshotRecordPermission; gql?: SnapshotGqlPermission; }; + typeHookExpr?: { create?: string; update?: string }; + typeValidateExpr?: string; } export type SnapshotSettings = NonNullable; diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.test.ts index 1f50f4c0c..8e41306fa 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.test.ts @@ -8,6 +8,7 @@ import { } from "@tailor-platform/tailor-proto/tailordb_resource_pb"; import * as path from "pathe"; import { describe, expect, expectTypeOf, test, beforeEach, afterAll, vi } from "vitest"; +import { buildTypeScripts } from "#/parser/service/tailordb/type-script"; import { createSnapshotFromLocalTypes, loadSnapshot, @@ -965,6 +966,135 @@ describe("snapshot", () => { }), ]); }); + + test("detects typeHookExpr addition", () => { + const previous: SchemaSnapshot = { + ...createEmptySnapshot(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { id: { type: "uuid", required: true } }, + }, + }, + }; + const current: SchemaSnapshot = { + ...createEmptySnapshot(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { id: { type: "uuid", required: true } }, + typeHookExpr: { create: "({input}) => ({fullName: input.first + input.last})" }, + }, + }, + }; + + const diff = compareSnapshots(previous, current); + + expect(diff.changes).toEqual([ + expect.objectContaining({ + kind: "type_scripts_modified", + typeName: "User", + before: {}, + after: { + typeHookExpr: { + create: "({input}) => ({fullName: input.first + input.last})", + }, + }, + }), + ]); + }); + + test("detects typeHookExpr removal", () => { + const previous: SchemaSnapshot = { + ...createEmptySnapshot(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { id: { type: "uuid", required: true } }, + typeHookExpr: { create: "old-expr" }, + }, + }, + }; + const current: SchemaSnapshot = { + ...createEmptySnapshot(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { id: { type: "uuid", required: true } }, + }, + }, + }; + + const diff = compareSnapshots(previous, current); + + expect(diff.changes).toEqual([ + expect.objectContaining({ + kind: "type_scripts_modified", + typeName: "User", + before: { typeHookExpr: { create: "old-expr" } }, + after: {}, + }), + ]); + }); + + test("detects typeValidateExpr change", () => { + const previous: SchemaSnapshot = { + ...createEmptySnapshot(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { id: { type: "uuid", required: true } }, + typeValidateExpr: "old-validate", + }, + }, + }; + const current: SchemaSnapshot = { + ...createEmptySnapshot(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { id: { type: "uuid", required: true } }, + typeValidateExpr: "new-validate", + }, + }, + }; + + const diff = compareSnapshots(previous, current); + + expect(diff.changes).toEqual([ + expect.objectContaining({ + kind: "type_scripts_modified", + typeName: "User", + before: { typeValidateExpr: "old-validate" }, + after: { typeValidateExpr: "new-validate" }, + }), + ]); + }); + + test("no diff when typeHookExpr unchanged", () => { + const snapshot: SchemaSnapshot = { + ...createEmptySnapshot(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { id: { type: "uuid", required: true } }, + typeHookExpr: { create: "same-expr", update: "same-update" }, + typeValidateExpr: "same-validate", + }, + }, + }; + + const diff = compareSnapshots(snapshot, snapshot); + + expect(diff.changes).toEqual([]); + }); }); // ========================================================================== @@ -3234,7 +3364,7 @@ describe("snapshot", () => { expect(drifts[0]!.details).toContain("allowedValues"); }); - test("reports detailed drift for hooks, validation, serial, and nested fields", () => { + test("reports detailed drift for serial and nested fields", () => { const snapshot: SchemaSnapshot = { version: SCHEMA_SNAPSHOT_VERSION, namespace, @@ -3248,13 +3378,6 @@ describe("snapshot", () => { metadata: { type: "nested", required: false, - hooks: { create: { expr: "snapshotCreate" } }, - validate: [ - { - script: { expr: "snapshotValid" }, - errorMessage: "Snapshot validation", - }, - ], serial: { start: 10, maxValue: 99, format: "S-%02d" }, fields: { child: { type: "string", required: false }, @@ -3271,14 +3394,6 @@ describe("snapshot", () => { metadata: { type: "nested", required: false, - hooks: { create: { expr: "remoteCreate" } }, - validate: [ - { - action: TailorDBType_PermitAction.ALLOW, - script: { expr: "remoteValid" }, - errorMessage: "Remote validation", - }, - ], serial: { start: 1, maxValue: 9, format: "R-%02d" }, fields: { child: { type: "number", required: false }, @@ -3290,8 +3405,6 @@ describe("snapshot", () => { const drifts = compareRemoteWithSnapshot(remoteTypes, snapshot); expect(drifts).toHaveLength(1); expect(drifts[0]!.kind).toBe("field_mismatch"); - expect(drifts[0]!.details).toContain("hooks.create"); - expect(drifts[0]!.details).toContain("validate[0].script"); expect(drifts[0]!.details).toContain("serial.start"); expect(drifts[0]!.details).toContain("fields.child.type"); }); @@ -3401,6 +3514,187 @@ describe("snapshot", () => { expect(drifts.length).toBe(1); expect(drifts[0]!.kind).toBe("type_missing_local"); }); + + test("detects script_mismatch when remote has no hash", () => { + const snapshot: SchemaSnapshot = { + version: SCHEMA_SNAPSHOT_VERSION, + namespace, + createdAt: new Date().toISOString(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { + id: { type: "uuid", required: true }, + name: { + type: "string", + required: true, + hooks: { create: { expr: "_value.trim()" } }, + }, + }, + }, + }, + }; + + const remoteTypes = [ + createMockRemoteType( + "User", + { + id: { type: "uuid", required: true }, + name: { type: "string", required: true }, + }, + { + typeHook: { + create: { expr: '((_invoker) => { return { "name": _value.trim() }; })()' }, + }, + }, + ), + ]; + + const drifts = compareRemoteWithSnapshot(remoteTypes, snapshot); + expect(drifts.some((d) => d.kind === "script_mismatch")).toBe(true); + }); + + test("detects script_mismatch when hashes differ", () => { + const snapshotFields = { + name: { + type: "string", + required: true, + hooks: { create: { expr: "_value.trim()" } }, + }, + }; + const snapshot: SchemaSnapshot = { + version: SCHEMA_SNAPSHOT_VERSION, + namespace, + createdAt: new Date().toISOString(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { id: { type: "uuid", required: true }, ...snapshotFields }, + }, + }, + }; + + const differentFields = { + name: { + type: "string", + required: true as const, + hooks: { create: { expr: "_value.toLowerCase()" } }, + }, + }; + const { typeHook } = buildTypeScripts(differentFields); + + const remoteTypes = [ + createMockRemoteType( + "User", + { id: { type: "uuid", required: true }, name: { type: "string", required: true } }, + { typeHook }, + ), + ]; + + const drifts = compareRemoteWithSnapshot(remoteTypes, snapshot); + expect(drifts.some((d) => d.kind === "script_mismatch")).toBe(true); + }); + + test("no script drift when hashes match", () => { + const snapshotFields = { + name: { + type: "string", + required: true, + hooks: { create: { expr: "_value.trim()" } }, + }, + }; + const snapshot: SchemaSnapshot = { + version: SCHEMA_SNAPSHOT_VERSION, + namespace, + createdAt: new Date().toISOString(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { id: { type: "uuid", required: true }, ...snapshotFields }, + }, + }, + }; + + const { typeHook } = buildTypeScripts(snapshotFields); + + const remoteTypes = [ + createMockRemoteType( + "User", + { id: { type: "uuid", required: true }, name: { type: "string", required: true } }, + { typeHook }, + ), + ]; + + const drifts = compareRemoteWithSnapshot(remoteTypes, snapshot); + expect(drifts.some((d) => d.kind === "script_mismatch")).toBe(false); + }); + + test("detects script drift when remote has scripts but snapshot does not", () => { + const snapshot: SchemaSnapshot = { + version: SCHEMA_SNAPSHOT_VERSION, + namespace, + createdAt: new Date().toISOString(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { + id: { type: "uuid", required: true }, + name: { type: "string", required: true }, + }, + }, + }, + }; + + const remoteTypes = [ + createMockRemoteType( + "User", + { + id: { type: "uuid", required: true }, + name: { type: "string", required: true }, + }, + { + typeHook: { + create: { expr: "someExpr() // @sdk-source-hash:abcdef0123456789" }, + }, + }, + ), + ]; + + const drifts = compareRemoteWithSnapshot(remoteTypes, snapshot); + expect(drifts.some((d) => d.kind === "script_mismatch")).toBe(true); + }); + + test("no script drift when neither side has scripts", () => { + const snapshot: SchemaSnapshot = { + version: SCHEMA_SNAPSHOT_VERSION, + namespace, + createdAt: new Date().toISOString(), + types: { + User: { + name: "User", + pluralForm: "Users", + fields: { + id: { type: "uuid", required: true }, + name: { type: "string", required: true }, + }, + }, + }, + }; + + const remoteTypes = [ + createMockRemoteType("User", { + id: { type: "uuid", required: true }, + name: { type: "string", required: true }, + }), + ]; + + const drifts = compareRemoteWithSnapshot(remoteTypes, snapshot); + expect(drifts.some((d) => d.kind === "script_mismatch")).toBe(false); + }); }); // ========================================================================== diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts index cf77827b3..6e8bacdee 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts @@ -23,6 +23,10 @@ import { import * as inflection from "inflection"; import * as path from "pathe"; import { z } from "zod"; +import { + computeSourceScriptHash, + extractSourceScriptHash, +} from "#/parser/service/tailordb/type-script"; import { assertDefined } from "#/utils/assert"; import { type MigrationDiff, @@ -30,6 +34,7 @@ import { type FieldDiffChange, type BreakingChangeInfo, type SnapshotTypeSettingsState, + type TypeScriptsState, type WarningChangeInfo, SCHEMA_SNAPSHOT_VERSION, } from "./diff-calculator"; @@ -324,6 +329,7 @@ function createSnapshotFieldConfigFromOperatorConfig( } if (fieldConfig.scale !== undefined) config.scale = fieldConfig.scale; + if (fieldConfig.default !== undefined) config.default = fieldConfig.default; // Recursive for nested fields if (fieldConfig.fields && Object.keys(fieldConfig.fields).length > 0) { @@ -398,6 +404,14 @@ export function createSnapshotType(type: TailorDBType): TailorDBSnapshotType { snapshotType.files = { ...type.files }; } + if (type.typeHookExpr) { + snapshotType.typeHookExpr = type.typeHookExpr; + } + + if (type.typeValidateExpr) { + snapshotType.typeValidateExpr = type.typeValidateExpr; + } + if (Object.keys(type.forwardRelationships).length > 0) { snapshotType.forwardRelationships = {}; for (const [relName, rel] of Object.entries(type.forwardRelationships)) { @@ -657,6 +671,20 @@ function applyDiffToSnapshot( } break; } + case "type_scripts_modified": { + const existing = types[change.typeName]; + if (existing) { + const { typeHookExpr: _, typeValidateExpr: __, ...rest } = existing; + types[change.typeName] = { + ...rest, + ...(change.after.typeHookExpr && { typeHookExpr: change.after.typeHookExpr }), + ...(change.after.typeValidateExpr !== undefined && { + typeValidateExpr: change.after.typeValidateExpr, + }), + }; + } + break; + } case "field_added": case "field_modified": { const existing = types[change.typeName]; @@ -939,6 +967,11 @@ function areFieldsDifferent(oldField: SnapshotFieldConfig, newField: SnapshotFie if (oldField.scale !== newField.scale) return true; + if (oldField.default !== newField.default) { + if (typeof oldField.default !== typeof newField.default) return true; + if (JSON.stringify(oldField.default) !== JSON.stringify(newField.default)) return true; + } + const oldFields = oldField.fields ?? {}; const newFields = newField.fields ?? {}; const oldFieldNames = Object.keys(oldFields); @@ -1558,6 +1591,33 @@ function compareTypeSettings( }); } +function typeScriptsState(type: TailorDBSnapshotType): TypeScriptsState { + return { + ...(type.typeHookExpr && { typeHookExpr: type.typeHookExpr }), + ...(type.typeValidateExpr !== undefined && { typeValidateExpr: type.typeValidateExpr }), + }; +} + +function compareTypeScripts( + ctx: DiffContext, + typeName: string, + previous: TailorDBSnapshotType, + current: TailorDBSnapshotType, +): void { + const prevState = typeScriptsState(previous); + const currState = typeScriptsState(current); + + if (JSON.stringify(prevState) === JSON.stringify(currState)) return; + + ctx.changes.push({ + kind: "type_scripts_modified", + typeName, + reason: "type-level scripts changed", + before: prevState, + after: currState, + }); +} + /** * Compare two snapshots and generate a diff * @param {SchemaSnapshot} previous - Previous schema snapshot @@ -1623,6 +1683,9 @@ function compareNormalizedSnapshots( // Compare type-level settings and metadata compareTypeSettings(ctx, typeName, prevType, currType); + // Compare type-level hook/validate scripts + compareTypeScripts(ctx, typeName, prevType, currType); + // Compare fields compareTypeFields(ctx, typeName, prevType, currType); @@ -2637,7 +2700,7 @@ export function compareRemoteWithSnapshot( snapshot: SchemaSnapshot, remoteGqlPermissions: readonly RemoteGqlPermission[] = [], ): SchemaDrift[] { - return compareNormalizedRemoteWithSnapshot( + const structuralDrifts = compareNormalizedRemoteWithSnapshot( createRemoteComparableSnapshot( createSnapshotFromRemoteTypes( remoteTypes, @@ -2648,6 +2711,90 @@ export function compareRemoteWithSnapshot( ), createRemoteComparableSnapshot(snapshot), ); + + const scriptDrifts = compareScriptHashes(remoteTypes, snapshot); + + return [...structuralDrifts, ...scriptDrifts]; +} + +function extractRemoteScriptHash(remoteType: ProtoTailorDBType): string | undefined { + const exprs = [ + remoteType.schema?.typeHook?.create?.expr, + remoteType.schema?.typeHook?.update?.expr, + remoteType.schema?.typeValidate?.create?.expr, + remoteType.schema?.typeValidate?.update?.expr, + ]; + let found: string | undefined; + for (const expr of exprs) { + if (expr) { + const hash = extractSourceScriptHash(expr); + if (hash) { + if (found && found !== hash) return undefined; + found = hash; + } + } + } + return found; +} + +function remoteHasScripts(remoteType: ProtoTailorDBType): boolean { + return !!( + remoteType.schema?.typeHook?.create?.expr || + remoteType.schema?.typeHook?.update?.expr || + remoteType.schema?.typeValidate?.create?.expr || + remoteType.schema?.typeValidate?.update?.expr + ); +} + +function compareScriptHashes( + remoteTypes: ProtoTailorDBType[], + snapshot: SchemaSnapshot, +): SchemaDrift[] { + const drifts: SchemaDrift[] = []; + const remoteByName = new Map(remoteTypes.map((t) => [t.name, t])); + + for (const [typeName, snapshotType] of Object.entries(snapshot.types)) { + const localHash = computeSourceScriptHash(snapshotType.fields, { + typeHookExpr: snapshotType.typeHookExpr, + typeValidateExpr: snapshotType.typeValidateExpr, + }); + + const remoteType = remoteByName.get(typeName); + if (!remoteType) continue; + + if (localHash) { + const remoteHash = extractRemoteScriptHash(remoteType); + if (localHash !== remoteHash) { + drifts.push({ + typeName, + kind: "script_mismatch", + details: remoteHash + ? `Type '${typeName}' scripts differ between remote and snapshot` + : `Type '${typeName}' has no script hash on remote`, + }); + } + } else if (remoteHasScripts(remoteType)) { + drifts.push({ + typeName, + kind: "script_mismatch", + details: `Type '${typeName}' has scripts on remote but not in snapshot`, + }); + } + } + + return drifts; +} + +function stripFieldScriptProps(field: SnapshotFieldConfig): SnapshotFieldConfig { + const { hooks: _hooks, validate: _validate, default: _default, ...rest } = field; + if (rest.fields) { + const nested = createSnapshotRecord(); + for (const [name, f] of Object.entries(rest.fields)) { + nested[name] = stripFieldScriptProps(f); + } + return { ...rest, fields: nested }; + } + return rest; } function createRemoteComparableSnapshot(snapshot: SchemaSnapshot): NormalizedSchemaSnapshot { @@ -2657,9 +2804,10 @@ function createRemoteComparableSnapshot(snapshot: SchemaSnapshot): NormalizedSch const fields = createSnapshotRecord(); for (const [fieldName, field] of Object.entries(type.fields)) { if (SYSTEM_FIELDS.has(fieldName)) continue; - fields[fieldName] = field; + fields[fieldName] = stripFieldScriptProps(field); } - types[typeName] = { ...type, fields }; + const { typeHookExpr: _, typeValidateExpr: __, ...typeRest } = type; + types[typeName] = { ...typeRest, fields }; } return normalizeSchemaSnapshot({ @@ -2792,6 +2940,12 @@ function schemaDriftFromDiffChange(change: DiffChange): SchemaDrift { kind: "permission_mismatch", details: change.reason ?? "Permissions differ between remote and snapshot", }; + case "type_scripts_modified": + return { + typeName: change.typeName, + kind: "script_mismatch", + details: change.reason ?? "Type-level scripts differ between remote and snapshot", + }; default: { change satisfies never; throw new Error("Unsupported diff change"); diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/status.ts b/packages/sdk/src/cli/commands/tailordb/migrate/status.ts index b615f24fa..7677f1877 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/status.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/status.ts @@ -163,15 +163,13 @@ export const statusCommand = defineAppCommand({ name: "status", description: "Show the current migration status for TailorDB namespaces, including applied and pending migrations.", - args: z - .object({ - ...deploymentArgs, - namespace: arg(z.string().optional(), { - alias: "n", - description: "Target TailorDB namespace (shows all namespaces if not specified)", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + namespace: arg(z.string().optional(), { + alias: "n", + description: "Target TailorDB namespace (shows all namespaces if not specified)", + }), + }), run: async (args) => { await status({ configPath: args.config, diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/sync.ts b/packages/sdk/src/cli/commands/tailordb/migrate/sync.ts index 8b048c62e..a45783f11 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/sync.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/sync.ts @@ -199,7 +199,7 @@ async function assertMigrationsReproduceLocalTypes( { mode: "plain" }, ); logger.info( - " - Type definitions changed without a new migration — run 'tailor-sdk tailordb migration generate' first.", + " - Type definitions changed without a new migration — run 'tailor tailordb migration generate' first.", { mode: "plain" }, ); logger.newline(); @@ -449,7 +449,7 @@ async function sync(options: SyncOptions): Promise { } catch (error) { handleOptionalToRequiredError(error, [ "The target snapshot marks a field as required, but existing remote records have no value for it.", - "Populate those records first (e.g. with a migration script applied via 'tailor-sdk deploy'), then re-run the sync.", + "Populate those records first (e.g. with a migration script applied via 'tailor deploy'), then re-run the sync.", ]); } await Promise.all( @@ -494,7 +494,7 @@ async function sync(options: SyncOptions): Promise { if (targetVersion < latest) { logger.newline(); logger.info( - `Run 'tailor-sdk deploy' to apply migrations ${formatMigrationNumber( + `Run 'tailor deploy' to apply migrations ${formatMigrationNumber( targetVersion + 1, )}–${formatMigrationNumber(latest)} from the working tree.`, ); @@ -505,21 +505,18 @@ export const syncCommand = defineAppCommand({ name: "sync", description: "Sync remote TailorDB schema to a specific migration snapshot (recovery from --no-schema-check drift).", - args: z - .object({ - ...deploymentArgs, - ...confirmationArgs, - number: arg(z.string(), { - positional: true, - description: - "Migration number to sync to (e.g., 0001 or 1; 0 targets the baseline snapshot)", - }), - namespace: arg(z.string().optional(), { - alias: "n", - description: "Target TailorDB namespace (required if multiple namespaces exist)", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...confirmationArgs, + number: arg(z.string(), { + positional: true, + description: "Migration number to sync to (e.g., 0001 or 1; 0 targets the baseline snapshot)", + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "Target TailorDB namespace (required if multiple namespaces exist)", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); await sync({ diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.ts b/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.ts index fd1d4b6ed..1cfe6e98d 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.ts @@ -164,7 +164,7 @@ export function generateMigrationScript(diff: MigrationDiff): string { * Migration script for ${diff.namespace} * * This script runs between the Pre-migration and Post-migration phases of - * 'tailor-sdk deploy'. Use it to transform existing data so that the schema + * 'tailor deploy'. Use it to transform existing data so that the schema * change can complete safely (for breaking changes, this is hard-required; * for warning-tier changes it is optional). Edit this file to implement * your data migration logic. diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/types.ts b/packages/sdk/src/cli/commands/tailordb/migrate/types.ts index 0896ce156..a33711c65 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/types.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/types.ts @@ -157,7 +157,8 @@ export type SchemaDriftKind = | "relationship_missing_remote" | "relationship_missing_local" | "relationship_mismatch" - | "permission_mismatch"; + | "permission_mismatch" + | "script_mismatch"; /** * Single schema drift item diff --git a/packages/sdk/src/cli/commands/tailordb/truncate.ts b/packages/sdk/src/cli/commands/tailordb/truncate.ts index 8633c3a04..cf495d4a4 100644 --- a/packages/sdk/src/cli/commands/tailordb/truncate.ts +++ b/packages/sdk/src/cli/commands/tailordb/truncate.ts @@ -207,24 +207,22 @@ async function $truncate(options: InternalTruncateOptions = {}): Promise { export const truncateCommand = defineAppCommand({ name: "truncate", description: "Truncate (delete all records from) TailorDB tables.", - args: z - .object({ - ...deploymentArgs, - ...confirmationArgs, - types: arg(z.string().array().optional(), { - positional: true, - description: "Type names to truncate", - }), - all: arg(z.boolean().default(false), { - alias: "a", - description: "Truncate all tables in all owned namespaces (excludes external namespaces)", - }), - namespace: arg(z.string().optional(), { - alias: "n", - description: "Truncate all tables in specified namespace", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...confirmationArgs, + types: arg(z.string().array().optional(), { + positional: true, + description: "Type names to truncate", + }), + all: arg(z.boolean().default(false), { + alias: "a", + description: "Truncate all tables in all owned namespaces (excludes external namespaces)", + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "Truncate all tables in specified namespace", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); const types = args.types && args.types.length > 0 ? args.types : undefined; diff --git a/packages/sdk/src/cli/commands/upgrade/index.ts b/packages/sdk/src/cli/commands/upgrade/index.ts index 2b4db9e6b..d222dc20b 100644 --- a/packages/sdk/src/cli/commands/upgrade/index.ts +++ b/packages/sdk/src/cli/commands/upgrade/index.ts @@ -6,21 +6,19 @@ import { defineAppCommand } from "#/cli/shared/command"; export const upgradeCommand = defineAppCommand({ name: "upgrade", description: "Run codemods to upgrade your project to a newer SDK version.", - args: z - .object({ - from: arg(z.string(), { - description: "SDK version before the upgrade (e.g., 1.33.0)", - }), - "dry-run": arg(z.boolean().default(false), { - alias: "d", - description: "Preview changes without modifying files", - }), - path: arg(z.string().default("."), { - description: "Project directory to upgrade", - completion: { type: "directory" }, - }), - }) - .strict(), + args: z.strictObject({ + from: arg(z.string(), { + description: "SDK version before the upgrade (e.g., 1.33.0)", + }), + "dry-run": arg(z.boolean().default(false), { + alias: "d", + description: "Preview changes without modifying files", + }), + path: arg(z.string().default("."), { + description: "Project directory to upgrade", + completion: { type: "directory" }, + }), + }), run: async (args) => { const { initTelemetry } = await import("#/cli/telemetry/index"); await initTelemetry(); diff --git a/packages/sdk/src/cli/commands/user/current.ts b/packages/sdk/src/cli/commands/user/current.ts index 5b5265729..d4bb3e55e 100644 --- a/packages/sdk/src/cli/commands/user/current.ts +++ b/packages/sdk/src/cli/commands/user/current.ts @@ -11,7 +11,7 @@ import ml from "#/utils/multiline"; export const currentCommand = defineAppCommand({ name: "current", description: "Show current user.", - args: z.object({}).strict(), + args: z.strictObject({}), run: async () => { const config = await readPlatformConfig(); const profile = process.env.TAILOR_PLATFORM_PROFILE; @@ -27,7 +27,7 @@ export const currentCommand = defineAppCommand({ if (!currentUser) { throw new Error(ml` Current user not set. - Please login first using 'tailor-sdk login' command to register a user. + Please login first using 'tailor login' command to register a user. `); } @@ -35,7 +35,7 @@ export const currentCommand = defineAppCommand({ if (!hasUserTokenEntry(config, currentUser, platformConfig)) { throw new Error(ml` Current user '${currentUser}' not found in registered users. - Please login again using 'tailor-sdk login' command to register the user. + Please login again using 'tailor login' command to register the user. `); } diff --git a/packages/sdk/src/cli/commands/user/list.ts b/packages/sdk/src/cli/commands/user/list.ts index 4c3eecf25..85db77699 100644 --- a/packages/sdk/src/cli/commands/user/list.ts +++ b/packages/sdk/src/cli/commands/user/list.ts @@ -47,7 +47,7 @@ function formatUserListInfo(info: UserListInfo): string { export const listCommand = defineAppCommand({ name: "list", description: "List all users.", - args: z.object({}).strict(), + args: z.strictObject({}), run: async () => { const config = await readPlatformConfig(); const jsonOutput = logger.jsonMode; @@ -56,7 +56,7 @@ export const listCommand = defineAppCommand({ if (users.length === 0) { logger.info(ml` No users found. - Please login first using 'tailor-sdk login' command to register a user. + Please login first using 'tailor login' command to register a user. `); if (jsonOutput) { logger.out([]); diff --git a/packages/sdk/src/cli/commands/user/pat/create.ts b/packages/sdk/src/cli/commands/user/pat/create.ts index bfc08b1e9..a81690783 100644 --- a/packages/sdk/src/cli/commands/user/pat/create.ts +++ b/packages/sdk/src/cli/commands/user/pat/create.ts @@ -8,18 +8,16 @@ import { createPatOperatorClient } from "./user"; export const createCommand = defineAppCommand({ name: "create", description: "Create a new personal access token.", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Token name", - }), - write: arg(z.boolean().default(false), { - alias: "W", - description: "Grant write permission (default: read-only)", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Token name", + }), + write: arg(z.boolean().default(false), { + alias: "W", + description: "Grant write permission (default: read-only)", + }), + }), run: async (args) => { await assertWritable(); const client = await createPatOperatorClient(); diff --git a/packages/sdk/src/cli/commands/user/pat/delete.ts b/packages/sdk/src/cli/commands/user/pat/delete.ts index 3c3e33ba9..e11af907b 100644 --- a/packages/sdk/src/cli/commands/user/pat/delete.ts +++ b/packages/sdk/src/cli/commands/user/pat/delete.ts @@ -8,14 +8,12 @@ import { createPatOperatorClient } from "./user"; export const deleteCommand = defineAppCommand({ name: "delete", description: "Delete a personal access token.", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Token name", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Token name", + }), + }), run: async (args) => { await assertWritable(); const client = await createPatOperatorClient(); diff --git a/packages/sdk/src/cli/commands/user/pat/list.test.ts b/packages/sdk/src/cli/commands/user/pat/list.test.ts index db0ab630b..4adb0bce5 100644 --- a/packages/sdk/src/cli/commands/user/pat/list.test.ts +++ b/packages/sdk/src/cli/commands/user/pat/list.test.ts @@ -1,11 +1,7 @@ import { runCommand } from "politty"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { initOperatorClient } from "#/cli/shared/client"; -import { - fetchLatestToken, - loadPlatformClientConfig, - readPlatformConfig, -} from "#/cli/shared/context"; +import { fetchLatestToken, readPlatformConfig } from "#/cli/shared/context"; import { jsonMode } from "#/cli/shared/test-helpers/json-mode"; import { listCommand } from "./list"; @@ -17,17 +13,16 @@ vi.mock("#/cli/shared/client", async (importOriginal) => ({ vi.mock("#/cli/shared/context", async (importOriginal) => ({ ...(await importOriginal()), fetchLatestToken: vi.fn(), - loadPlatformClientConfig: vi.fn(), readPlatformConfig: vi.fn(), })); describe("user pat list", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(loadPlatformClientConfig).mockResolvedValue({ - platformUrl: "https://api.dev.tailor.tech", + vi.mocked(fetchLatestToken).mockResolvedValue({ + accessToken: "scoped-token", + user: "u@example.com", }); - vi.mocked(fetchLatestToken).mockResolvedValue("scoped-token"); vi.mocked(initOperatorClient).mockResolvedValue({ listPersonalAccessTokens: vi.fn().mockResolvedValue({ personalAccessTokens: [], @@ -42,8 +37,8 @@ describe("user pat list", () => { test("uses the active profile platform when loading the current user's token", async () => { const config = { - version: 2, - min_sdk_version: "1.29.0", + version: 3, + min_sdk_version: "2.0.0", users: {}, profiles: { dev: { @@ -53,7 +48,7 @@ describe("user pat list", () => { }, }, current_user: null, - } as Awaited>; + } satisfies Awaited>; vi.stubEnv("TAILOR_PLATFORM_PROFILE", "dev"); vi.mocked(readPlatformConfig).mockResolvedValue(config); using _json = jsonMode(); diff --git a/packages/sdk/src/cli/commands/user/pat/list.ts b/packages/sdk/src/cli/commands/user/pat/list.ts index 66e978876..68547a185 100644 --- a/packages/sdk/src/cli/commands/user/pat/list.ts +++ b/packages/sdk/src/cli/commands/user/pat/list.ts @@ -10,7 +10,7 @@ import { createPatOperatorClient } from "./user"; export const listCommand = defineAppCommand({ name: "list", description: "List all personal access tokens.", - args: z.object({ ...paginationArgs() }).strict(), + args: z.strictObject({ ...paginationArgs() }), run: async (args) => { const jsonOutput = logger.jsonMode; const client = await createPatOperatorClient(); @@ -31,7 +31,7 @@ export const listCommand = defineAppCommand({ if (pats.length === 0) { logger.info(ml` No personal access tokens found. - Please create a token using 'tailor-sdk user pat create' command. + Please create a token using 'tailor user pat create' command. `); if (!jsonOutput) { return; diff --git a/packages/sdk/src/cli/commands/user/pat/update.ts b/packages/sdk/src/cli/commands/user/pat/update.ts index 60cd42b87..81f43d62b 100644 --- a/packages/sdk/src/cli/commands/user/pat/update.ts +++ b/packages/sdk/src/cli/commands/user/pat/update.ts @@ -8,18 +8,16 @@ import { createPatOperatorClient } from "./user"; export const updateCommand = defineAppCommand({ name: "update", description: "Update a personal access token (delete and recreate).", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Token name", - }), - write: arg(z.boolean().default(false), { - alias: "W", - description: "Grant write permission (if not specified, keeps read-only)", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Token name", + }), + write: arg(z.boolean().default(false), { + alias: "W", + description: "Grant write permission (if not specified, keeps read-only)", + }), + }), run: async (args) => { await assertWritable(); const client = await createPatOperatorClient(); diff --git a/packages/sdk/src/cli/commands/user/pat/user.ts b/packages/sdk/src/cli/commands/user/pat/user.ts index 1d70a4b56..ba1198e5d 100644 --- a/packages/sdk/src/cli/commands/user/pat/user.ts +++ b/packages/sdk/src/cli/commands/user/pat/user.ts @@ -26,9 +26,9 @@ export async function createPatOperatorClient() { const user = resolvePatUser(config); if (!user) { - throw new Error("No user logged in.\nPlease login first using 'tailor-sdk login' command."); + throw new Error("No user logged in.\nPlease login first using 'tailor login' command."); } - const token = await fetchLatestToken(config, user, platformConfig); - return await initOperatorClient(token, platformConfig); + const { accessToken } = await fetchLatestToken(config, user, platformConfig); + return await initOperatorClient(accessToken, platformConfig); } diff --git a/packages/sdk/src/cli/commands/user/switch.test.ts b/packages/sdk/src/cli/commands/user/switch.test.ts index d62be18b4..7ebd57dcf 100644 --- a/packages/sdk/src/cli/commands/user/switch.test.ts +++ b/packages/sdk/src/cli/commands/user/switch.test.ts @@ -34,29 +34,55 @@ describe("user switch", () => { beforeEach(() => { vi.clearAllMocks(); resetKeyringState(); + vi.stubEnv("TAILOR_PLATFORM_PROFILE", undefined); + vi.stubEnv("TAILOR_PLATFORM_URL", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); + if (fs.existsSync(configPath)) fs.rmSync(configPath); + }); + + test("stores the subject-keyed user when switching by email metadata", async () => { writePlatformConfig({ - version: 2, - min_sdk_version: "1.29.0", + version: 3, + min_sdk_version: "2.0.0", users: { - "https://api.dev.tailor.tech|u@example.com": { + "platform-user-sub": { storage: "file", access_token: "token", + refresh_token: "refresh", token_expires_at: "2999-01-01T00:00:00.000Z", + email: "user@example.com", }, }, profiles: {}, current_user: null, }); - }); - afterEach(() => { - vi.unstubAllEnvs(); - const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); - if (fs.existsSync(configPath)) fs.rmSync(configPath); + const result = await runCommand(switchCommand, ["user@example.com"]); + + expect(result.success).toBe(true); + const config = await readPlatformConfig(); + expect(config.current_user).toBe("platform-user-sub"); }); test("stores the bare user when switching to a TAILOR_PLATFORM_URL-scoped token", async () => { vi.stubEnv("TAILOR_PLATFORM_URL", "https://api.dev.tailor.tech"); + writePlatformConfig({ + version: 3, + min_sdk_version: "2.0.0", + users: { + "https://api.dev.tailor.tech|u@example.com": { + storage: "file", + access_token: "token", + token_expires_at: "2999-01-01T00:00:00.000Z", + }, + }, + profiles: {}, + current_user: null, + }); const result = await runCommand(switchCommand, ["u@example.com"]); @@ -66,19 +92,39 @@ describe("user switch", () => { }); test("updates the active profile user when switching users", async () => { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "https://api.dev.tailor.tech|u@example.com": { + storage: "file", + access_token: "token", + token_expires_at: "2999-01-01T00:00:00.000Z", + }, + }, + profiles: {}, + current_user: null, + }); vi.stubEnv("TAILOR_PLATFORM_PROFILE", "dev"); - const config = await readPlatformConfig(); - config.users["https://api.dev.tailor.tech|other@example.com"] = { - storage: "file", - access_token: "other-token", - token_expires_at: "2999-01-01T00:00:00.000Z", - }; - config.profiles.dev = { - user: "u@example.com", - workspace_id: "12345678-1234-4abc-8def-123456789012", - platform_url: "https://api.dev.tailor.tech", - }; - writePlatformConfig(config); + writePlatformConfig({ + version: 3, + min_sdk_version: "2.0.0", + users: { + "https://api.dev.tailor.tech|other@example.com": { + storage: "file", + access_token: "other-token", + token_expires_at: "2999-01-01T00:00:00.000Z", + }, + }, + profiles: { + dev: { + user: "u@example.com", + workspace_id: "12345678-1234-4abc-8def-123456789012", + platform_url: "https://api.dev.tailor.tech", + }, + }, + current_user: null, + }); const result = await runCommand(switchCommand, ["other@example.com"]); @@ -89,6 +135,20 @@ describe("user switch", () => { }); test("rejects scoped token keys as current user values", async () => { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "https://api.dev.tailor.tech|u@example.com": { + storage: "file", + access_token: "token", + token_expires_at: "2999-01-01T00:00:00.000Z", + }, + }, + profiles: {}, + current_user: null, + }); + const result = await runCommand(switchCommand, ["https://api.dev.tailor.tech|u@example.com"]); expect(result.success).toBe(false); diff --git a/packages/sdk/src/cli/commands/user/switch.ts b/packages/sdk/src/cli/commands/user/switch.ts index a9d583867..6f9067247 100644 --- a/packages/sdk/src/cli/commands/user/switch.ts +++ b/packages/sdk/src/cli/commands/user/switch.ts @@ -2,9 +2,9 @@ import { arg } from "politty"; import { z } from "zod"; import { defineAppCommand } from "#/cli/shared/command"; import { - hasUserTokenEntry, platformConfigFromProfile, readPlatformConfig, + resolveConfigUser, writePlatformConfig, } from "#/cli/shared/context"; import { logger } from "#/cli/shared/logger"; @@ -13,14 +13,12 @@ import ml from "#/utils/multiline"; export const switchCommand = defineAppCommand({ name: "switch", description: "Set current user.", - args: z - .object({ - user: arg(z.string(), { - positional: true, - description: "User email", - }), - }) - .strict(), + args: z.strictObject({ + user: arg(z.string(), { + positional: true, + description: "User email address or machine user client ID", + }), + }), run: async (args) => { const config = await readPlatformConfig(); const activeProfileName = process.env.TAILOR_PLATFORM_PROFILE; @@ -38,21 +36,21 @@ export const switchCommand = defineAppCommand({ ); } - // Check if user exists - if (!hasUserTokenEntry(config, args.user, platformConfig)) { + const user = resolveConfigUser(config, args.user, platformConfig); + if (!user) { throw new Error(ml` User "${args.user}" not found. - Please login first using 'tailor-sdk login' command to register this user. + Please login first using 'tailor login' command to register this user. `); } if (activeProfileEntry) { - activeProfileEntry.user = args.user; + activeProfileEntry.user = user; } else { - config.current_user = args.user; + config.current_user = user; } writePlatformConfig(config); - logger.success(`Current user set to "${args.user}" successfully.`); + logger.success(`Current user set to "${user}" successfully.`); }, }); diff --git a/packages/sdk/src/cli/commands/workflow/executions.ts b/packages/sdk/src/cli/commands/workflow/executions.ts index 7b7066991..b2d64944a 100644 --- a/packages/sdk/src/cli/commands/workflow/executions.ts +++ b/packages/sdk/src/cli/commands/workflow/executions.ts @@ -354,37 +354,35 @@ export function printExecutionWithLogs(execution: WorkflowExecutionDetailInfo): export const executionsCommand = defineAppCommand({ name: "executions", description: "List or get workflow executions.", - args: z - .object({ - ...workspaceArgs, - ...pagedLogArgs, - "execution-id": arg(z.string().optional(), { - positional: true, - description: "Execution ID (if provided, shows details)", - }), - "workflow-name": arg( - z - .string() - .regex( - /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/, - "Must be 3-63 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric", - ) - .optional(), - { - alias: "n", - description: "Filter by workflow name (list mode only)", - }, - ), - status: arg(z.string().optional(), { - alias: "s", - description: "Filter by status (list mode only)", - }), - ...waitArgs, - logs: arg(z.boolean().default(false), { - description: "Display job execution logs (detail mode only)", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...pagedLogArgs, + "execution-id": arg(z.string().optional(), { + positional: true, + description: "Execution ID (if provided, shows details)", + }), + "workflow-name": arg( + z + .string() + .regex( + /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/, + "Must be 3-63 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric", + ) + .optional(), + { + alias: "n", + description: "Filter by workflow name (list mode only)", + }, + ), + status: arg(z.string().optional(), { + alias: "s", + description: "Filter by status (list mode only)", + }), + ...waitArgs, + logs: arg(z.boolean().default(false), { + description: "Display job execution logs (detail mode only)", + }), + }), run: async (args) => { const jsonOutput = logger.jsonMode || args.json; if (args.executionId) { diff --git a/packages/sdk/src/cli/commands/workflow/get.ts b/packages/sdk/src/cli/commands/workflow/get.ts index 082529452..19d0969b0 100644 --- a/packages/sdk/src/cli/commands/workflow/get.ts +++ b/packages/sdk/src/cli/commands/workflow/get.ts @@ -88,12 +88,10 @@ export async function getWorkflow( export const getCommand = defineAppCommand({ name: "get", description: "Get workflow details.", - args: z - .object({ - ...workspaceArgs, - ...nameArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...nameArgs, + }), run: async (args) => { const workflow = await getWorkflow({ name: args.name, diff --git a/packages/sdk/src/cli/commands/workflow/list.ts b/packages/sdk/src/cli/commands/workflow/list.ts index 854984e37..50e86ef6b 100644 --- a/packages/sdk/src/cli/commands/workflow/list.ts +++ b/packages/sdk/src/cli/commands/workflow/list.ts @@ -48,12 +48,10 @@ export async function listWorkflows(options?: ListWorkflowsOptions): Promise { const jsonOutput = logger.jsonMode; const workflows = await listWorkflows({ diff --git a/packages/sdk/src/cli/commands/workflow/resume.ts b/packages/sdk/src/cli/commands/workflow/resume.ts index faa0b5205..96548a1fc 100644 --- a/packages/sdk/src/cli/commands/workflow/resume.ts +++ b/packages/sdk/src/cli/commands/workflow/resume.ts @@ -77,16 +77,14 @@ export async function resumeWorkflow( export const resumeCommand = defineAppCommand({ name: "resume", description: "Resume a failed or pending workflow execution.", - args: z - .object({ - ...workspaceArgs, - "execution-id": arg(z.string(), { - positional: true, - description: "Failed execution ID", - }), - ...waitArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + "execution-id": arg(z.string(), { + positional: true, + description: "Failed execution ID", + }), + ...waitArgs, + }), run: async (args) => { const jsonOutput = logger.jsonMode || args.json; const { executionId, wait } = await resumeWorkflow({ diff --git a/packages/sdk/src/cli/commands/workflow/start.api-types.test.ts b/packages/sdk/src/cli/commands/workflow/start.api-types.test.ts index ac479ae75..2e79e8b47 100644 --- a/packages/sdk/src/cli/commands/workflow/start.api-types.test.ts +++ b/packages/sdk/src/cli/commands/workflow/start.api-types.test.ts @@ -1,25 +1,13 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. import { describe, test, expectTypeOf } from "vitest"; -import { defineAuth } from "#/configure/services/auth/index"; -import { db } from "#/configure/services/tailordb/index"; import { createWorkflow, createWorkflowJob } from "#/configure/services/workflow/index"; import { type StartWorkflowOptions, type StartWorkflowTypedOptions } from "./start"; -const userType = db.type("User", { - email: db.string().unique(), -}); - -const auth = defineAuth("main-auth", { - userProfile: { - type: userType, - usernameField: "email", - }, - machineUsers: { - admin: {}, - worker: {}, - }, -}); - +// `invoker` is typed as `MachineUserName`, which falls back to `string` until +// `tailor.d.ts` augments `MachineUserNameRegistry`. Narrowing to the registered +// machine user union (and rejection of unknown names) is covered against a real +// generated `tailor.d.ts` in `example/`; here we only assert arg inference and +// that machine user names are accepted as strings. const calculationJob = createWorkflowJob({ name: "calculation", body: (input: { a: number; b: number }) => ({ sum: input.a + input.b }), @@ -102,13 +90,13 @@ describe("startWorkflow API types", () => { test("infers arg type from workflow", () => { acceptsCalculationWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { a: 1, b: 2 }, }); acceptsCalculationWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", // @ts-expect-error - arg shape must match workflow input arg: { x: 1, y: 2 }, }); @@ -121,44 +109,41 @@ describe("startWorkflow API types", () => { // @ts-expect-error - arg is required for workflows with input acceptsCalculationWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", }); }); test("does not allow arg for workflows without input", () => { acceptsNoInputWorkflowOptions({ workflow: noInputWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", }); acceptsNoInputWorkflowOptions({ workflow: noInputWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", // @ts-expect-error - no-input workflow must not receive arg arg: { any: "value" }, }); }); - test("keeps machine user names type-safe via auth.invoker", () => { + test("accepts machine user names as strings", () => { acceptsCalculationWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("worker"), + invoker: "worker", arg: { a: 1, b: 2 }, }); - - // @ts-expect-error - invalid machine user name - auth.invoker("invalid-machine-user"); }); test("keeps default generic usable when StartWorkflowTypedOptions generic is omitted", () => { acceptsDefaultWorkflowOptions({ workflow: noInputWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", }); acceptsDefaultWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { a: 1, b: 2 }, }); }); @@ -166,26 +151,26 @@ describe("startWorkflow API types", () => { test("supports union workflow types without collapsing arg type", () => { acceptsUnionWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { a: 1, b: 2 }, }); acceptsUnionWorkflowOptions({ workflow: noInputWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", }); }); test("supports union workflow input types without collapsing arg type", () => { acceptsUnionInputWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { a: 1, b: 2 }, }); acceptsUnionInputWorkflowOptions({ workflow: textWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { message: "hello" }, }); @@ -198,13 +183,13 @@ describe("startWorkflow API types", () => { test("supports plain workflow unions without collapsing arg type", () => { acceptsPlainUnionWorkflowOptions({ workflow: plainWorkflowA, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { foo: 1 }, }); acceptsPlainUnionWorkflowOptions({ workflow: plainWorkflowB, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { bar: "x" }, }); @@ -235,7 +220,7 @@ describe("startWorkflow API types", () => { acceptsDeprecatedOptions({ // @ts-expect-error - deprecated options must keep legacy name/machineUser shape workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { a: 1, b: 2 }, }); }); diff --git a/packages/sdk/src/cli/commands/workflow/start.test.ts b/packages/sdk/src/cli/commands/workflow/start.test.ts index 9edc53907..6197a4b6a 100644 --- a/packages/sdk/src/cli/commands/workflow/start.test.ts +++ b/packages/sdk/src/cli/commands/workflow/start.test.ts @@ -85,10 +85,7 @@ describe("startWorkflow runtime overload", () => { body: () => undefined, }, }, - authInvoker: { - namespace: "typed-ns", - machineUserName: "typed-user", - }, + invoker: "typed-user", } as never); expect(loadConfig).toHaveBeenCalledTimes(1); @@ -102,10 +99,92 @@ describe("startWorkflow runtime overload", () => { expect(testStartWorkflowMock).toHaveBeenCalledWith( expect.objectContaining({ workflowId: "id:legacy-workflow", + authInvoker: expect.objectContaining({ + namespace: "auth-ns", + machineUserName: "legacy-user", + }), }), ); }); + test("typed shape resolves auth namespace from config and sends proto credentials", async () => { + await startWorkflow({ + workflow: { + name: "typed-workflow", + mainJob: { + body: () => undefined, + }, + }, + invoker: "typed-user", + }); + + expect(loadConfig).toHaveBeenCalledTimes(1); + expect(getApplicationMock).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + applicationName: "my-app", + }); + expect(testStartWorkflowMock).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: "id:typed-workflow", + authInvoker: expect.objectContaining({ + namespace: "auth-ns", + machineUserName: "typed-user", + }), + }), + ); + }); + + test("typed shape falls back to external auth config name", async () => { + vi.mocked(loadConfig).mockResolvedValueOnce({ + config: { + name: "my-app", + auth: { name: "external-auth", external: true }, + }, + } as Awaited>); + getApplicationMock.mockResolvedValueOnce({ + application: {}, + }); + + await startWorkflow({ + workflow: { + name: "typed-workflow", + mainJob: { + body: () => undefined, + }, + }, + invoker: "typed-user", + }); + + expect(testStartWorkflowMock).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: "id:typed-workflow", + authInvoker: expect.objectContaining({ + namespace: "external-auth", + machineUserName: "typed-user", + }), + }), + ); + }); + + test("throws when neither the deployed app nor the config has an auth namespace", async () => { + getApplicationMock.mockResolvedValueOnce({ + application: {}, + }); + + await expect( + startWorkflow({ + workflow: { + name: "typed-workflow", + mainJob: { + body: () => undefined, + }, + }, + invoker: "typed-user", + }), + ).rejects.toThrow("my-app does not have an auth configuration"); + expect(testStartWorkflowMock).not.toHaveBeenCalled(); + }); + test("start command with jsonMode emits only parseable JSON to stdout", async () => { using stdout = captureStdout(); using stderr = captureStderr(); diff --git a/packages/sdk/src/cli/commands/workflow/start.ts b/packages/sdk/src/cli/commands/workflow/start.ts index 8ac2410ef..2c65d678e 100644 --- a/packages/sdk/src/cli/commands/workflow/start.ts +++ b/packages/sdk/src/cli/commands/workflow/start.ts @@ -23,6 +23,10 @@ import { waitForWorkflowExecution, type WorkflowWaitResult, } from "./waiter"; +// Import from the public entry (not `@/types/auth`) so the `./cli` d.ts references +// `@tailor-platform/sdk` externally instead of inlining the registry — a single +// generated `declare module "@tailor-platform/sdk"` then narrows both entries. +import type { MachineUserName } from "@tailor-platform/sdk"; import type { Jsonifiable } from "type-fest"; type WorkflowLike = { @@ -32,7 +36,7 @@ type WorkflowLike = { }; }; -type AuthInvoker = { +type WorkflowInvoker = { namespace: string; machineUserName: M; }; @@ -72,9 +76,10 @@ type StartWorkflowByNameOptions = StartWorkflowOptions & { type StartWorkflowTypedBaseOptions = { workflow: W; - authInvoker: AuthInvoker; + invoker: MachineUserName; workspaceId?: string; profile?: string; + configPath?: string; interval?: number; }; @@ -98,7 +103,7 @@ interface StartWorkflowCoreOptions { client: Awaited>; workspaceId: string; workflowName: string; - authInvoker: AuthInvoker; + invoker: WorkflowInvoker; arg?: unknown; interval?: number; } @@ -110,7 +115,7 @@ async function startWorkflowCore( try { const workflow = await resolveWorkflow(client, workspaceId, workflowName); - const authInvoker = create(AuthInvokerSchema, options.authInvoker); + const invoker = create(AuthInvokerSchema, options.invoker); const arg = options.arg === undefined ? undefined @@ -121,7 +126,7 @@ async function startWorkflowCore( const { executionId } = await client.testStartWorkflow({ workspaceId, workflowId: workflow.id, - authInvoker, + authInvoker: invoker, arg, }); @@ -147,6 +152,23 @@ async function startWorkflowCore( } } +async function resolveApplicationAuthNamespace(options: { + client: Awaited>; + workspaceId: string; + configPath?: string; +}): Promise { + const { config } = await loadConfig(options.configPath); + const { application } = await options.client.getApplication({ + workspaceId: options.workspaceId, + applicationName: config.name, + }); + const authNamespace = application?.authNamespace || config.auth?.name; + if (!authNamespace) { + throw new Error(`Application ${config.name} does not have an auth configuration.`); + } + return authNamespace; +} + async function startWorkflowByName( options: StartWorkflowByNameOptions, ): Promise { @@ -157,7 +179,7 @@ async function startWorkflowByName( }); if (!machineUser) { throw new Error( - "Machine user is required. Specify --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor-sdk profile update --machine-user '.", + "Machine user is required. Specify --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor profile update --machine-user '.", ); } @@ -170,21 +192,18 @@ async function startWorkflowByName( profile: options.profile, }); - const { config } = await loadConfig(options.configPath); - const { application } = await client.getApplication({ + const authNamespace = await resolveApplicationAuthNamespace({ + client, workspaceId, - applicationName: config.name, + configPath: options.configPath, }); - if (!application?.authNamespace) { - throw new Error(`Application ${config.name} does not have an auth configuration.`); - } return await startWorkflowCore({ client, workspaceId, workflowName: options.name, - authInvoker: { - namespace: application.authNamespace, + invoker: { + namespace: authNamespace, machineUserName: machineUser, }, arg: options.arg, @@ -219,12 +238,20 @@ export async function startWorkflow( workspaceId: options.workspaceId, profile: options.profile, }); + const authNamespace = await resolveApplicationAuthNamespace({ + client, + workspaceId, + configPath: options.configPath, + }); return await startWorkflowCore({ client, workspaceId, workflowName: options.workflow.name, - authInvoker: options.authInvoker, + invoker: { + namespace: authNamespace, + machineUserName: options.invoker, + }, arg: options.arg, interval: options.interval, }); @@ -233,23 +260,20 @@ export async function startWorkflow( export const startCommand = defineAppCommand({ name: "start", description: "Start a workflow execution.", - args: z - .object({ - ...deploymentArgs, - ...nameArgs, - "machine-user": arg(z.string().optional(), { - alias: "m", - hiddenAlias: "machineuser", - description: "Machine user name. Falls back to the active profile's default machine user.", - env: "TAILOR_PLATFORM_MACHINE_USER_NAME", - }), - arg: arg(z.string().optional(), { - alias: "a", - description: "Workflow argument (JSON string)", - }), - ...waitArgs, - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...nameArgs, + "machine-user": arg(z.string().optional(), { + alias: "m", + description: "Machine user name. Falls back to the active profile's default machine user.", + env: "TAILOR_PLATFORM_MACHINE_USER_NAME", + }), + arg: arg(z.string().optional(), { + alias: "a", + description: "Workflow argument (JSON string)", + }), + ...waitArgs, + }), run: async (args) => { const { executionId, wait } = await startWorkflowByName({ name: args.name, diff --git a/packages/sdk/src/cli/commands/workflow/wait.ts b/packages/sdk/src/cli/commands/workflow/wait.ts index c2f19d224..5f244927d 100644 --- a/packages/sdk/src/cli/commands/workflow/wait.ts +++ b/packages/sdk/src/cli/commands/workflow/wait.ts @@ -71,16 +71,14 @@ export const waitCommand = defineAppCommand({ desc: "Wait for success, failure, or suspension", }, ], - args: z - .object({ - ...workspaceArgs, - "execution-id": arg(z.string(), { - positional: true, - description: "Execution ID", - }), - ...workflowWaitControlArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + "execution-id": arg(z.string(), { + positional: true, + description: "Execution ID", + }), + ...workflowWaitControlArgs, + }), run: async (args) => { const jsonOutput = logger.jsonMode || args.json; const result = await waitWorkflowExecution({ diff --git a/packages/sdk/src/cli/commands/workspace/app/health.ts b/packages/sdk/src/cli/commands/workspace/app/health.ts index 807190d6d..c84b75b86 100644 --- a/packages/sdk/src/cli/commands/workspace/app/health.ts +++ b/packages/sdk/src/cli/commands/workspace/app/health.ts @@ -9,6 +9,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { appHealthInfo, type AppHealthInfo } from "./transform"; +// strip unknown keys const healthOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -56,15 +57,13 @@ export async function getAppHealth(options: HealthOptions): Promise { const health = await getAppHealth({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/workspace/app/list.ts b/packages/sdk/src/cli/commands/workspace/app/list.ts index 45f0ef57a..f747ac5dd 100644 --- a/packages/sdk/src/cli/commands/workspace/app/list.ts +++ b/packages/sdk/src/cli/commands/workspace/app/list.ts @@ -8,6 +8,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { appInfo, type AppInfo } from "./transform"; +// strip unknown keys const listAppsOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -66,12 +67,10 @@ export async function listApps(options: ListAppsOptions): Promise { export const listCommand = defineAppCommand({ name: "list", description: "List applications in a workspace", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const jsonOutput = logger.jsonMode; const apps = await listApps({ diff --git a/packages/sdk/src/cli/commands/workspace/create.ts b/packages/sdk/src/cli/commands/workspace/create.ts index 8d4ee8be5..92a5c4118 100644 --- a/packages/sdk/src/cli/commands/workspace/create.ts +++ b/packages/sdk/src/cli/commands/workspace/create.ts @@ -11,11 +11,11 @@ import { } from "#/cli/shared/client"; import { defineAppCommand } from "#/cli/shared/command"; import { - hasUserTokenEntry, loadAccessToken, loadPlatformClientConfig, platformConfigFromProfile, readPlatformConfig, + resolveConfigUser, writePlatformConfig, } from "#/cli/shared/context"; import { logger } from "#/cli/shared/logger"; @@ -36,6 +36,7 @@ import type { ProfileInfo } from "../profile"; * - name: 3-63 chars, lowercase alphanumeric and hyphens, cannot start/end with hyphen * - organizationId, folderId: optional UUIDs */ +// strip unknown keys const createWorkspaceOptionsSchema = z.object({ name: workspaceNameSchema, region: z.string(), @@ -128,47 +129,46 @@ export { validateWorkspaceName } from "#/cli/shared/workspace-name"; export const createCommand = defineAppCommand({ name: "create", description: "Create a new Tailor Platform workspace.", - args: z - .object({ - name: arg(z.string(), { - alias: "n", - description: "Workspace name", - }), - region: arg(z.string(), { - alias: "r", - description: "Workspace region (us-west, asia-northeast)", - }), - "delete-protection": arg(z.boolean().default(false), { - alias: "d", - description: "Enable delete protection", - }), - "organization-id": arg(z.string().optional(), { - alias: "o", - description: "Organization ID to workspace associate with", - env: "TAILOR_PLATFORM_ORGANIZATION_ID", - }), - "folder-id": arg(z.string().optional(), { - alias: "f", - description: "Folder ID to workspace associate with", - env: "TAILOR_PLATFORM_FOLDER_ID", - }), - "profile-name": arg(z.string().optional(), { - alias: "p", - description: "Profile name to create", - }), - profile: arg(profileNameSchema.optional(), { - description: "Workspace profile used for authentication and Platform selection", - env: "TAILOR_PLATFORM_PROFILE", - }), - "profile-user": arg(z.string().optional(), { - description: "User email for the profile (defaults to current user)", - }), - permission: arg(z.enum(["write", "read"]).default("write"), { - description: - "Profile permission (requires --profile-name). 'read' blocks all write commands while the profile is active.", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + alias: "n", + description: "Workspace name", + }), + region: arg(z.string(), { + alias: "r", + description: "Workspace region (us-west, asia-northeast)", + }), + "delete-protection": arg(z.boolean().default(false), { + alias: "d", + description: "Enable delete protection", + }), + "organization-id": arg(z.string().optional(), { + alias: "o", + description: "Organization ID to workspace associate with", + env: "TAILOR_PLATFORM_ORGANIZATION_ID", + }), + "folder-id": arg(z.string().optional(), { + alias: "f", + description: "Folder ID to workspace associate with", + env: "TAILOR_PLATFORM_FOLDER_ID", + }), + "profile-name": arg(z.string().optional(), { + alias: "p", + description: "Profile name to create", + }), + profile: arg(profileNameSchema.optional(), { + description: "Workspace profile used for authentication and Platform selection", + env: "TAILOR_PLATFORM_PROFILE", + }), + "profile-user": arg(z.string().optional(), { + description: + "User email address or machine user client ID for the profile (defaults to current user)", + }), + permission: arg(z.enum(["write", "read"]).default("write"), { + description: + "Profile permission (requires --profile-name). 'read' blocks all write commands while the profile is active.", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); const profileName = args["profile-name"]; @@ -197,14 +197,15 @@ export const createCommand = defineAppCommand({ ); } - if (!hasUserTokenEntry(config, profileUser, platformConfig)) { + const resolvedProfileUser = resolveConfigUser(config, profileUser, platformConfig); + if (!resolvedProfileUser) { throw new Error( - `User "${profileUser}" not found.\nPlease verify your user name and login using 'tailor-sdk login' command.`, + `User "${profileUser}" not found.\nPlease verify your user name and login using 'tailor login' command.`, ); } profileSetup = { name: profileName, - user: profileUser, + user: resolvedProfileUser, platformSettings: profilePlatformSettings(platformConfig), }; } diff --git a/packages/sdk/src/cli/commands/workspace/delete.ts b/packages/sdk/src/cli/commands/workspace/delete.ts index 22f09b820..24048fc43 100644 --- a/packages/sdk/src/cli/commands/workspace/delete.ts +++ b/packages/sdk/src/cli/commands/workspace/delete.ts @@ -10,6 +10,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { resolveWorkspaceFolderName, workspaceDisplayName } from "./transform"; +// strip unknown keys const deleteWorkspaceOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }), }); @@ -50,15 +51,13 @@ export async function deleteWorkspace(options: DeleteWorkspaceOptions): Promise< export const deleteCommand = defineAppCommand({ name: "delete", description: "Delete a Tailor Platform workspace.", - args: z - .object({ - "workspace-id": arg(z.string(), { - alias: "w", - description: "Workspace ID", - }), - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + "workspace-id": arg(z.string(), { + alias: "w", + description: "Workspace ID", + }), + ...confirmationArgs, + }), run: async (args) => { await assertWritable(); // Load and validate options diff --git a/packages/sdk/src/cli/commands/workspace/get.ts b/packages/sdk/src/cli/commands/workspace/get.ts index 31ce0c6f0..7224f1a9f 100644 --- a/packages/sdk/src/cli/commands/workspace/get.ts +++ b/packages/sdk/src/cli/commands/workspace/get.ts @@ -12,6 +12,7 @@ import { type WorkspaceDetails, } from "./transform"; +// strip unknown keys const getWorkspaceOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -60,11 +61,9 @@ export async function getWorkspace(options: GetWorkspaceOptions): Promise { const workspace = await getWorkspace({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/workspace/list.ts b/packages/sdk/src/cli/commands/workspace/list.ts index d7b0c7d80..95b27a0b5 100644 --- a/packages/sdk/src/cli/commands/workspace/list.ts +++ b/packages/sdk/src/cli/commands/workspace/list.ts @@ -60,15 +60,13 @@ export async function listWorkspacesWithClient( export const listCommand = defineAppCommand({ name: "list", description: "List all Tailor Platform workspaces.", - args: z - .object({ - ...paginationArgs(), - profile: arg(profileNameSchema.optional(), { - description: "Workspace profile used for authentication and Platform selection", - env: "TAILOR_PLATFORM_PROFILE", - }), - }) - .strict(), + args: z.strictObject({ + ...paginationArgs(), + profile: arg(profileNameSchema.optional(), { + description: "Workspace profile used for authentication and Platform selection", + env: "TAILOR_PLATFORM_PROFILE", + }), + }), run: async (args) => { const workspaces = await listWorkspaces({ order: args.order, diff --git a/packages/sdk/src/cli/commands/workspace/restore.ts b/packages/sdk/src/cli/commands/workspace/restore.ts index 7efff4a27..321433790 100644 --- a/packages/sdk/src/cli/commands/workspace/restore.ts +++ b/packages/sdk/src/cli/commands/workspace/restore.ts @@ -9,6 +9,7 @@ import { prompt } from "#/cli/shared/prompt"; import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; +// strip unknown keys const restoreWorkspaceOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }), }); @@ -46,15 +47,13 @@ export async function restoreWorkspace(options: RestoreWorkspaceOptions): Promis export const restoreCommand = defineAppCommand({ name: "restore", description: "Restore a deleted workspace", - args: z - .object({ - "workspace-id": arg(z.string(), { - alias: "w", - description: "Workspace ID", - }), - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + "workspace-id": arg(z.string(), { + alias: "w", + description: "Workspace ID", + }), + ...confirmationArgs, + }), run: async (args) => { await assertWritable(); const { client, workspaceId } = await loadOptions({ diff --git a/packages/sdk/src/cli/commands/workspace/user/invite.ts b/packages/sdk/src/cli/commands/workspace/user/invite.ts index e13e3cf08..c6c0bdc26 100644 --- a/packages/sdk/src/cli/commands/workspace/user/invite.ts +++ b/packages/sdk/src/cli/commands/workspace/user/invite.ts @@ -9,6 +9,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { stringToRole, validRoles } from "./transform"; +// strip unknown keys const inviteUserOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -57,18 +58,16 @@ export async function inviteUser(options: InviteUserOptions): Promise { export const inviteCommand = defineAppCommand({ name: "invite", description: "Invite a user to a workspace", - args: z - .object({ - ...workspaceArgs, - email: arg(z.email(), { - description: "Email address of the user to invite", - }), - role: arg(z.enum(validRoles), { - description: `Role to assign (${validRoles.join(", ")})`, - alias: "r", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + email: arg(z.email(), { + description: "Email address of the user to invite", + }), + role: arg(z.enum(validRoles), { + description: `Role to assign (${validRoles.join(", ")})`, + alias: "r", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); await inviteUser({ diff --git a/packages/sdk/src/cli/commands/workspace/user/list.ts b/packages/sdk/src/cli/commands/workspace/user/list.ts index ea28f2d3c..559e1b2b2 100644 --- a/packages/sdk/src/cli/commands/workspace/user/list.ts +++ b/packages/sdk/src/cli/commands/workspace/user/list.ts @@ -7,6 +7,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { userInfo, type UserInfo } from "./transform"; +// strip unknown keys const listUsersOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -65,12 +66,10 @@ export async function listUsers(options: ListUsersOptions): Promise export const listCommand = defineAppCommand({ name: "list", description: "List users in a workspace", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const users = await listUsers({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/workspace/user/remove.ts b/packages/sdk/src/cli/commands/workspace/user/remove.ts index 3de9e9a19..a5f260f7d 100644 --- a/packages/sdk/src/cli/commands/workspace/user/remove.ts +++ b/packages/sdk/src/cli/commands/workspace/user/remove.ts @@ -9,6 +9,7 @@ import { prompt } from "#/cli/shared/prompt"; import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; +// strip unknown keys const removeUserOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -54,15 +55,13 @@ export async function removeUser(options: RemoveUserOptions): Promise { export const removeCommand = defineAppCommand({ name: "remove", description: "Remove a user from a workspace", - args: z - .object({ - ...workspaceArgs, - email: arg(z.email(), { - description: "Email address of the user to remove", - }), - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + email: arg(z.email(), { + description: "Email address of the user to remove", + }), + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); if (!args.yes) { diff --git a/packages/sdk/src/cli/commands/workspace/user/update.ts b/packages/sdk/src/cli/commands/workspace/user/update.ts index f3d98a12d..d185e4524 100644 --- a/packages/sdk/src/cli/commands/workspace/user/update.ts +++ b/packages/sdk/src/cli/commands/workspace/user/update.ts @@ -9,6 +9,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { stringToRole, validRoles } from "./transform"; +// strip unknown keys const updateUserOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -57,18 +58,16 @@ export async function updateUser(options: UpdateUserOptions): Promise { export const updateCommand = defineAppCommand({ name: "update", description: "Update a user's role in a workspace", - args: z - .object({ - ...workspaceArgs, - email: arg(z.email(), { - description: "Email address of the user to update", - }), - role: arg(z.enum(validRoles), { - description: `New role to assign (${validRoles.join(", ")})`, - alias: "r", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + email: arg(z.email(), { + description: "Email address of the user to update", + }), + role: arg(z.enum(validRoles), { + description: `New role to assign (${validRoles.join(", ")})`, + alias: "r", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); await updateUser({ diff --git a/packages/sdk/src/cli/completion.test.ts b/packages/sdk/src/cli/completion.test.ts index 1b9cff99a..36f596f57 100644 --- a/packages/sdk/src/cli/completion.test.ts +++ b/packages/sdk/src/cli/completion.test.ts @@ -41,7 +41,6 @@ describe("shell completion", () => { test("completes nested subcommands for tailordb", async () => { const values = await completeValues(["tailordb", ""]); - expect(values).toContain("erd"); expect(values).toContain("migration"); expect(values).toContain("truncate"); }); @@ -77,13 +76,13 @@ describe("shell completion", () => { }); describe("directory completion", () => { - test.each([ - ["staticwebsite deploy --dir", ["staticwebsite", "deploy", "--dir", ""]], - ["tailordb erd export --output", ["tailordb", "erd", "export", "--output", ""]], - ])("triggers directory completion for %s", async (_label, args) => { - const result = await complete(args); - expect(result.directive & CompletionDirective.DirectoryCompletion).toBeTruthy(); - }); + test.each([["staticwebsite deploy --dir", ["staticwebsite", "deploy", "--dir", ""]]])( + "triggers directory completion for %s", + async (_label, args) => { + const result = await complete(args); + expect(result.directive & CompletionDirective.DirectoryCompletion).toBeTruthy(); + }, + ); }); describe("no file completion", () => { @@ -120,7 +119,7 @@ describe("shell completion", () => { candidates: readonly { value: string; description?: string }[]; }[]; } { - const data = extractCompletionData(mainCommand, "tailor-sdk"); + const data = extractCompletionData(mainCommand, "tailor"); const apiCmd = data.command.subcommands.find((s) => s.name === "api"); if (!apiCmd) throw new Error("api subcommand missing"); const fieldOpt = apiCmd.options.find((o) => o.name === "field"); @@ -186,9 +185,9 @@ describe("shell completion", () => { // repeated. Confirm both are wired up in the zsh script. const { script } = generateCompletion(mainCommand, { shell: "zsh", - programName: "tailor-sdk", + programName: "tailor", }); - expect(script).toMatch(/__tailor_sdk_expand_[a-z_]+__field=/); + expect(script).toMatch(/__tailor_expand_[a-z_]+__field=/); expect(script).toContain("GetFunctionExecution"); expect(script).toContain("_used_field_keys"); }); diff --git a/packages/sdk/src/cli/crashreport/index.ts b/packages/sdk/src/cli/crashreport/index.ts index 274b24f0f..aee9be83b 100644 --- a/packages/sdk/src/cli/crashreport/index.ts +++ b/packages/sdk/src/cli/crashreport/index.ts @@ -35,7 +35,7 @@ export async function reportCrash(error: unknown, errorType: ErrorType): Promise ` ${filePath}`, "", "To submit this report:", - ` tailor-sdk crashreport send --file "${filePath}"`, + ` tailor crashreport send --file "${filePath}"`, ].join("\n"), ); } diff --git a/packages/sdk/src/cli/crashreport/report.ts b/packages/sdk/src/cli/crashreport/report.ts index ca879c4f6..f70b2f00d 100644 --- a/packages/sdk/src/cli/crashreport/report.ts +++ b/packages/sdk/src/cli/crashreport/report.ts @@ -84,26 +84,44 @@ export function buildCrashReport(options: BuildCrashReportOptions): CrashReport errorMessage: sanitizeMessage(rawMessage), stackTrace: sanitizeStackTrace(rawStack), errorType, - userId: currentUser, - userEmail: currentUser, + userId: currentUser?.id ?? null, + userEmail: currentUser?.email ?? null, }; } +type CurrentUser = { + id: string; + email: string | null; +}; + /** * Read current_user from Tailor Platform config without side effects. * Unlike readPlatformConfig(), this never triggers migration or logs warnings. - * @returns The current user email, or null if unavailable + * @returns The current user ID and email, or null if unavailable */ -function readCurrentUser(): string | null { +function readCurrentUser(): CurrentUser | null { try { if (!xdgConfig) return null; const configPath = path.join(xdgConfig, "tailor-platform", "config.yaml"); if (!fs.existsSync(configPath)) return null; - const raw = parseYAML(fs.readFileSync(configPath, "utf-8")) as { current_user?: string | null }; + const raw = parseYAML(fs.readFileSync(configPath, "utf-8")) as { + current_user?: string | null; + users?: Record; + }; // parseYAML returns null for empty documents // oxlint-disable-next-line typescript/no-unnecessary-condition - return raw?.current_user ?? null; + const currentUser = raw?.current_user ?? null; + if (!currentUser) return null; + const email = raw.users?.[currentUser]?.email; + return { + id: currentUser, + email: typeof email === "string" ? email : legacyEmail(currentUser), + }; } catch { return null; } } + +function legacyEmail(user: string): string | null { + return user.includes("@") ? user : null; +} diff --git a/packages/sdk/src/cli/crashreport/sanitize.test.ts b/packages/sdk/src/cli/crashreport/sanitize.test.ts index 05b4aa7c1..f308c8a32 100644 --- a/packages/sdk/src/cli/crashreport/sanitize.test.ts +++ b/packages/sdk/src/cli/crashreport/sanitize.test.ts @@ -128,42 +128,42 @@ describe("sanitizeMessage", () => { describe("sanitizeArgv", () => { test("keeps command and subcommand names", () => { - const argv = ["node", "tailor-sdk", "apply"]; - expect(sanitizeArgv(argv)).toEqual(["node", "tailor-sdk", "apply"]); + const argv = ["node", "tailor", "apply"]; + expect(sanitizeArgv(argv)).toEqual(["node", "tailor", "apply"]); }); test.each([ { name: "redacts value after any long flag (space format)", - argv: ["node", "tailor-sdk", "show", "--workspace-id", "some-uuid"], - expected: ["node", "tailor-sdk", "show", "--workspace-id", ""], + argv: ["node", "tailor", "show", "--workspace-id", "some-uuid"], + expected: ["node", "tailor", "show", "--workspace-id", ""], }, { name: "redacts value after any short flag (space format)", - argv: ["node", "tailor-sdk", "show", "-w", "some-uuid"], - expected: ["node", "tailor-sdk", "show", "-w", ""], + argv: ["node", "tailor", "show", "-w", "some-uuid"], + expected: ["node", "tailor", "show", "-w", ""], }, { name: "redacts value after any flag regardless of flag name", - argv: ["node", "tailor-sdk", "apply", "--region", "asia-northeast"], - expected: ["node", "tailor-sdk", "apply", "--region", ""], + argv: ["node", "tailor", "apply", "--region", "asia-northeast"], + expected: ["node", "tailor", "apply", "--region", ""], }, { name: "treats consecutive flags correctly (no value between them)", - argv: ["node", "tailor-sdk", "apply", "--verbose", "--yes"], - expected: ["node", "tailor-sdk", "apply", "--verbose", "--yes"], + argv: ["node", "tailor", "apply", "--verbose", "--yes"], + expected: ["node", "tailor", "apply", "--verbose", "--yes"], }, { name: "redacts value after boolean flag followed by valued flag", - argv: ["node", "tailor-sdk", "apply", "--verbose", "--workspace-id", "secret"], - expected: ["node", "tailor-sdk", "apply", "--verbose", "--workspace-id", ""], + argv: ["node", "tailor", "apply", "--verbose", "--workspace-id", "secret"], + expected: ["node", "tailor", "apply", "--verbose", "--workspace-id", ""], }, ])("$name", ({ argv, expected }) => { expect(sanitizeArgv(argv)).toEqual(expected); }); test("redacts --flag=value (equals format)", () => { - const argv = ["node", "tailor-sdk", "show", "--workspace-id=some-uuid"]; + const argv = ["node", "tailor", "show", "--workspace-id=some-uuid"]; const result = sanitizeArgv(argv); expect(result).toContain("--workspace-id="); expect(result).not.toContain("some-uuid"); @@ -172,19 +172,19 @@ describe("sanitizeArgv", () => { test.each([ { name: "redacts absolute path positional arguments", - argv: ["node", "tailor-sdk", "/home/user/project/tailor.config.ts"], + argv: ["node", "tailor", "/home/user/project/tailor.config.ts"], contains: "", excludes: "/home/user/", }, { name: "redacts Windows-style absolute path positional arguments", - argv: ["node", "tailor-sdk", "C:\\Users\\admin\\project\\tailor.config.ts"], + argv: ["node", "tailor", "C:\\Users\\admin\\project\\tailor.config.ts"], contains: "", excludes: "C:\\Users\\admin", }, { name: "redacts email address positional arguments", - argv: ["node", "tailor-sdk", "user", "switch", "user@example.com"], + argv: ["node", "tailor", "user", "switch", "user@example.com"], contains: "", excludes: "user@example.com", }, diff --git a/packages/sdk/src/cli/crashreport/sender.test.ts b/packages/sdk/src/cli/crashreport/sender.test.ts index 0de3ba59b..8709af866 100644 --- a/packages/sdk/src/cli/crashreport/sender.test.ts +++ b/packages/sdk/src/cli/crashreport/sender.test.ts @@ -12,7 +12,7 @@ function makeCrashReport(): CrashReport { osRelease: "25.3.0", arch: "arm64", command: "apply", - argv: ["node", "tailor-sdk", "apply"], + argv: ["node", "tailor", "apply"], errorName: "TypeError", errorMessage: "Cannot read properties of undefined", stackTrace: "TypeError: Cannot read properties of undefined", @@ -51,7 +51,7 @@ describe("sendCrashReport", () => { mockFetchResolvedValue({ data: { submitCrashReport: { success: true } } }); const report = makeCrashReport(); - await sendCrashReport(report, "tailor-sdk/1.0.0"); + await sendCrashReport(report, "tailor/1.0.0"); const call = vi.mocked(globalThis.fetch).mock.calls[0]!; const body = JSON.parse(call[1]!.body as string); @@ -67,7 +67,7 @@ describe("sendCrashReport", () => { mockFetchResolvedValue({ data: { submitCrashReport: { success: true } } }); const report = makeCrashReport(); - await sendCrashReport(report, "tailor-sdk/1.0.0"); + await sendCrashReport(report, "tailor/1.0.0"); const call = vi.mocked(globalThis.fetch).mock.calls[0]!; const { variables } = JSON.parse(call[1]!.body as string); @@ -98,7 +98,7 @@ describe("sendCrashReport", () => { ])("%s", async (_name, response, expected) => { mockFetchResolvedValue(response); - const result = await sendCrashReport(makeCrashReport(), "tailor-sdk/1.0.0"); + const result = await sendCrashReport(makeCrashReport(), "tailor/1.0.0"); expect(result).toBe(expected); }); @@ -110,7 +110,7 @@ describe("sendCrashReport", () => { json: () => Promise.resolve({}), }); - const result = await sendCrashReport(makeCrashReport(), "tailor-sdk/1.0.0"); + const result = await sendCrashReport(makeCrashReport(), "tailor/1.0.0"); expect(result).toBe(false); }); @@ -118,7 +118,7 @@ describe("sendCrashReport", () => { test("returns false on network error", async () => { globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network error")); - const result = await sendCrashReport(makeCrashReport(), "tailor-sdk/1.0.0"); + const result = await sendCrashReport(makeCrashReport(), "tailor/1.0.0"); expect(result).toBe(false); }); @@ -127,7 +127,7 @@ describe("sendCrashReport", () => { mockFetchResolvedValue({ data: { submitCrashReport: { success: true } } }); process.env.TAILOR_CRASH_REPORT_ENDPOINT = "https://custom.example.com/query"; - await sendCrashReport(makeCrashReport(), "tailor-sdk/1.0.0"); + await sendCrashReport(makeCrashReport(), "tailor/1.0.0"); expect(globalThis.fetch).toHaveBeenCalledWith( "https://custom.example.com/query", @@ -138,14 +138,14 @@ describe("sendCrashReport", () => { test("sends Content-Type application/json and User-Agent headers", async () => { mockFetchResolvedValue({ data: { submitCrashReport: { success: true } } }); - await sendCrashReport(makeCrashReport(), "tailor-sdk/1.0.0"); + await sendCrashReport(makeCrashReport(), "tailor/1.0.0"); expect(globalThis.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ "Content-Type": "application/json", - "User-Agent": "tailor-sdk/1.0.0", + "User-Agent": "tailor/1.0.0", }), }), ); diff --git a/packages/sdk/src/cli/crashreport/writer.test.ts b/packages/sdk/src/cli/crashreport/writer.test.ts index ae5915565..e4a078364 100644 --- a/packages/sdk/src/cli/crashreport/writer.test.ts +++ b/packages/sdk/src/cli/crashreport/writer.test.ts @@ -15,7 +15,7 @@ function makeCrashReport(overrides?: Partial): CrashReport { osRelease: "25.3.0", arch: "arm64", command: "apply", - argv: ["node", "tailor-sdk", "apply"], + argv: ["node", "tailor", "apply"], errorName: "TypeError", errorMessage: "Cannot read properties of undefined", stackTrace: @@ -45,12 +45,10 @@ describe("formatCrashReport", () => { test("serializes argv as JSON array", () => { const report = makeCrashReport({ - argv: ["node", "tailor-sdk", "apply", "--body", '{"a": "b c"}'], + argv: ["node", "tailor", "apply", "--body", '{"a": "b c"}'], }); const text = formatCrashReport(report); - expect(text).toContain( - 'Arguments: ["node","tailor-sdk","apply","--body","{\\"a\\": \\"b c\\"}"]', - ); + expect(text).toContain('Arguments: ["node","tailor","apply","--body","{\\"a\\": \\"b c\\"}"]'); }); test("handles empty stack trace", () => { diff --git a/packages/sdk/src/cli/docs.test.ts b/packages/sdk/src/cli/docs.test.ts index 11310dbe6..a12e6ebce 100644 --- a/packages/sdk/src/cli/docs.test.ts +++ b/packages/sdk/src/cli/docs.test.ts @@ -25,7 +25,7 @@ const templateFiles: [output: string, commands: string[]][] = [ ["application", ["init", "generate", "deploy", "remove", "show", "open", "api"]], ["tailordb", ["tailordb"]], ["query", ["query"]], - ["user", ["login", "logout", "user"]], + ["user", ["login", "logout", "auth", "user"]], ["organization", ["organization"]], ["workspace", ["workspace", "profile"]], ["auth", ["authconnection", "machineuser", "oauth2client"]], @@ -38,6 +38,7 @@ const templateFiles: [output: string, commands: string[]][] = [ ["setup", ["setup"]], ["upgrade", ["upgrade"]], ["skills", ["skills"]], + ["plugin", ["plugin"]], ["completion", ["completion"]], ]; @@ -65,6 +66,7 @@ describe("CLI Documentation", () => { command: mainCommand, templates, targetCommands, + // strip unknown keys globalArgs: z.object(commonArgs), formatter: mdFormatter, }); diff --git a/packages/sdk/src/cli/index.ts b/packages/sdk/src/cli/index.ts index 89972f06f..fe1390cc7 100644 --- a/packages/sdk/src/cli/index.ts +++ b/packages/sdk/src/cli/index.ts @@ -1,9 +1,13 @@ #!/usr/bin/env node -import { defineCommand, runMain } from "politty"; +import { dirname, resolve } from "pathe"; +import { resolvePackageJSON } from "pkg-types"; +import { defineCommand, runCommand, runMain, type AnyCommand } from "politty"; import { withCompletionCommand } from "politty/completion"; +import { withSkillCommand } from "politty/skill"; import { z } from "zod"; import { apiCommand } from "./commands/api"; +import { authCommand } from "./commands/auth"; import { authconnectionCommand } from "./commands/authconnection"; import { crashReportCommand } from "./commands/crashreport"; import { deployCommand } from "./commands/deploy"; @@ -17,12 +21,12 @@ import { machineuserCommand } from "./commands/machineuser"; import { oauth2clientCommand } from "./commands/oauth2client"; import { openCommand } from "./commands/open"; import { organizationCommand } from "./commands/organization"; +import { pluginCommand } from "./commands/plugin"; import { profileCommand } from "./commands/profile"; import { removeCommand } from "./commands/remove"; import { secretCommand } from "./commands/secret"; import { setupCommand } from "./commands/setup"; import { showCommand } from "./commands/show"; -import { skillsCommand } from "./commands/skills"; import { staticwebsiteCommand } from "./commands/staticwebsite"; import { tailordbCommand } from "./commands/tailordb"; import { upgradeCommand } from "./commands/upgrade"; @@ -35,9 +39,10 @@ import { commonArgs, isVerbose } from "./shared/args"; import { errorToJson, isCLIError } from "./shared/errors"; import { logger } from "./shared/logger"; import { readPackageJson } from "./shared/package-json"; -import { registerTypeScriptRuntime } from "./shared/register-typescript-runtime"; +import { dispatchPluginWithInstallHint } from "./shared/plugin"; +import { registerTsHook } from "./shared/register-ts-hook"; -await registerTypeScriptRuntime(new URL("./tsconfig-paths-hook.mjs", import.meta.url)); +await registerTsHook(new URL("./ts-hook.mjs", import.meta.url)); // Runs before globalArgs effects load --env-file, so env file overrides for // TAILOR_CRASH_REPORTS_* are not available for early startup failures. @@ -46,15 +51,58 @@ await registerTypeScriptRuntime(new URL("./tsconfig-paths-hook.mjs", import.meta initCrashReporting(); const packageJson = await readPackageJson(); -const cliName = Object.keys(packageJson.bin ?? {})[0] || "tailor-sdk"; +const cliName = Object.keys(packageJson.bin ?? {})[0] || "tailor"; +const packageName = packageJson.name ?? "@tailor-platform/sdk"; +const packageJsonPath = await resolvePackageJSON(import.meta.url); +const bundledSkillsDir = resolve(dirname(packageJsonPath), "agent-skills"); -export const mainCommand = withCompletionCommand( +function alignSkillCommand(command: AnyCommand): AnyCommand { + const subCommands = command.subCommands ?? {}; + const add = { + ...(subCommands.add as AnyCommand), + description: "Install Tailor SDK agent skills.", + }; + const list = { + ...(subCommands.list as AnyCommand), + description: "List Tailor SDK agent skills.", + }; + const remove = { + ...(subCommands.remove as AnyCommand), + description: "Remove installed Tailor SDK agent skills.", + }; + const sync = { + ...(subCommands.sync as AnyCommand), + description: "Remove and reinstall Tailor SDK agent skills.", + }; + return { + ...command, + description: "Manage Tailor SDK agent skills.", + async run() { + const result = await runCommand(add, []); + if (!result.success) { + throw result.error; + } + }, + subCommands: { + ...subCommands, + add, + list, + remove, + sync, + }, + }; +} + +const commandWithSkills = withSkillCommand( defineCommand({ name: cliName, description: packageJson.description || "Tailor CLI for managing Tailor Platform SDK applications", + notes: `CLI plugins (beta): an unknown subcommand is dispatched to an external plugin executable named \`${cliName}-\` (found on your PATH or in node_modules/.bin), similar to \`gh\` extensions. +Run \`${cliName} plugin list\` to see which plugins are installed and where they resolve from.`, subCommands: { api: apiCommand, + auth: authCommand, authconnection: authconnectionCommand, crashreport: crashReportCommand, deploy: deployCommand, @@ -68,13 +116,13 @@ export const mainCommand = withCompletionCommand( oauth2client: oauth2clientCommand, open: openCommand, organization: organizationCommand, + plugin: pluginCommand, profile: profileCommand, query: queryCommand, remove: removeCommand, secret: secretCommand, setup: setupCommand, show: showCommand, - skills: skillsCommand, staticwebsite: staticwebsiteCommand, tailordb: tailordbCommand, upgrade: upgradeCommand, @@ -83,12 +131,41 @@ export const mainCommand = withCompletionCommand( workspace: workspaceCommand, }, }), + { + sourceDir: bundledSkillsDir, + package: packageName, + mode: "copy", + descriptionAppend: false, + // strip unknown keys + globalArgs: z.object(commonArgs), + commandMap: { add: ["add"], remove: ["remove"] }, + unknownKeys: "strict", + }, ); +export const mainCommand = withCompletionCommand({ + ...commandWithSkills, + subCommands: { + ...commandWithSkills.subCommands, + skills: alignSkillCommand(commandWithSkills.subCommands.skills), + }, +}); + runMain(mainCommand, { version: packageJson.version, + // strip unknown keys globalArgs: z.object(commonArgs), displayErrors: false, + // CLI plugin dispatch: an unknown subcommand at any level execs the external + // `tailor--` binary, forwarding args and injecting context. + onUnknownSubcommand: ({ commandPath, name, args }) => + dispatchPluginWithInstallHint({ + commandPath, + name, + args, + cliName, + profile: process.env.TAILOR_PLATFORM_PROFILE, + }), cleanup: async ({ error }) => { if (error) { if (logger.jsonMode) { diff --git a/packages/sdk/src/cli/lib.ts b/packages/sdk/src/cli/lib.ts index 5b3a4011f..b2112a7f0 100644 --- a/packages/sdk/src/cli/lib.ts +++ b/packages/sdk/src/cli/lib.ts @@ -1,7 +1,7 @@ // CLI API exports for programmatic usage -import { registerTypeScriptRuntime } from "./shared/register-typescript-runtime"; +import { registerTsHook } from "./shared/register-ts-hook"; -await registerTypeScriptRuntime(new URL("./tsconfig-paths-hook.mjs", import.meta.url)); +await registerTsHook(new URL("./ts-hook.mjs", import.meta.url)); export { deploy, deploy as apply } from "./commands/deploy/deploy"; export type { DeployOptions, DeployOptions as ApplyOptions } from "./commands/deploy/deploy"; @@ -10,24 +10,26 @@ export { generate } from "./commands/generate/service"; export type { GenerateOptions } from "./commands/generate/options"; export { loadConfig, type LoadedConfig } from "./shared/config-loader"; export { generateUserTypes } from "./shared/type-generator"; +export { + loadTailorDBNamespaces, + type TailorDBNamespaceSelector, + type LoadTailorDBNamespacesOptions, + type LoadedTailorDBNamespaces, +} from "./shared/tailordb-namespaces"; +export { + deployStaticWebsite, + type DeployResult as StaticWebsiteDeployResult, +} from "./commands/staticwebsite/deploy"; +export { assertWritable } from "./shared/readonly-guard"; +export { isPluginGeneratedType } from "#/parser/service/tailordb/type-source"; +export type { GeneratorResult, PluginAttachment, TailorDBNamespaceData } from "#/plugin/types"; export type { - CodeGenerator, - TailorDBGenerator, - ResolverGenerator, - ExecutorGenerator, - TailorDBResolverGenerator, - FullCodeGenerator, - TailorDBInput, - ResolverInput, - ExecutorInput, - FullInput, - AggregateArgs, - GeneratorResult, - DependencyKind, - PluginAttachment, + TailorDBType, TypeSourceInfoEntry, -} from "./commands/generate/types"; -export type { TailorDBType } from "#/parser/service/tailordb/types"; + ParsedField, + OperatorFieldConfig, + PluginGeneratedTypeSource, +} from "#/parser/service/tailordb/types"; export type { Resolver } from "#/types/resolver.generated"; export type { Executor } from "#/types/executor.generated"; @@ -90,6 +92,7 @@ export { type GetWorkflowOptions, type GetWorkflowTypedOptions, } from "./commands/workflow/get"; +export type { MachineUserName } from "@tailor-platform/sdk"; export { startWorkflow, type StartWorkflowOptions, diff --git a/packages/sdk/src/cli/options.test.ts b/packages/sdk/src/cli/options.test.ts index c0aef192e..5c61f4ac4 100644 --- a/packages/sdk/src/cli/options.test.ts +++ b/packages/sdk/src/cli/options.test.ts @@ -1,5 +1,6 @@ import { extractFields, isLazyCommand } from "politty"; import { describe, expect, test, vi } from "vitest"; +import { BUILTIN_COMMAND_NAMES } from "./shared/builtin-commands"; import { mainCommand } from "./index"; import type { AnyCommand, ExtractedFields, SubCommandValue } from "politty"; @@ -85,7 +86,7 @@ async function walkMainCommands(visit: (command: AnyCommand, path: string[]) => const subCommands = mainCommand.subCommands; expect(subCommands).toBeDefined(); - for (const [name, cmd] of Object.entries(subCommands ?? {})) { + for (const [name, cmd] of Object.entries(subCommands)) { await walkCommand(cmd, visit, [name]); } } @@ -114,4 +115,14 @@ describe("CLI options", () => { expect(checked).toBeGreaterThan(0); }); + + test("keeps BUILTIN_COMMAND_NAMES in sync with the registered subcommands", () => { + // `plugin list` uses BUILTIN_COMMAND_NAMES (a leaf module, to avoid an + // import cycle) to flag shadowed plugins. Exclude the wrapper-added + // `completion` command and any internal `__`-prefixed commands. + const registered = Object.keys(mainCommand.subCommands).filter( + (name) => !name.startsWith("__") && name !== "completion", + ); + expect(new Set(registered)).toEqual(new Set(BUILTIN_COMMAND_NAMES)); + }); }); diff --git a/packages/sdk/src/cli/query/errors.ts b/packages/sdk/src/cli/query/errors.ts index 9152f2838..916c9dedc 100644 --- a/packages/sdk/src/cli/query/errors.ts +++ b/packages/sdk/src/cli/query/errors.ts @@ -27,7 +27,7 @@ export function mapQueryExecutionError(args: MapQueryExecutionErrorArgs): Error return CLIError({ code: "not_found", message: `Machine user '${args.machineUser ?? "unknown"}' was not found.`, - suggestion: "Run `tailor-sdk machineuser list` and use an existing name.", + suggestion: "Run `tailor machineuser list` and use an existing name.", }); } diff --git a/packages/sdk/src/cli/query/index.test.ts b/packages/sdk/src/cli/query/index.test.ts index 11e21572c..85a2dbc9a 100644 --- a/packages/sdk/src/cli/query/index.test.ts +++ b/packages/sdk/src/cli/query/index.test.ts @@ -281,7 +281,7 @@ describe("query", () => { expect(executeScript).toHaveBeenCalled(); const call = vi.mocked(executeScript).mock.calls[0]![0]!; - const arg = JSON.parse(call.arg ?? "{}"); + const arg = call.arg as unknown as { queries: string[] }; expect(arg.queries).toEqual(["SELECT 1; ", "SELECT 2"]); }); @@ -297,7 +297,7 @@ describe("query", () => { }); const call = vi.mocked(executeScript).mock.calls[0]![0]!; - const arg = JSON.parse(call.arg ?? "{}"); + const arg = call.arg as unknown as { queries: string[] }; expect(arg.queries).toHaveLength(1); }); @@ -327,11 +327,11 @@ describe("query", () => { expect(executeScript).toHaveBeenCalledWith( expect.objectContaining({ name: "query-gql.js", - arg: JSON.stringify({ + arg: { endpoint: "https://app.example.com/query", accessToken: "mu-token", query: "{ viewer { id } }", - }), + }, }), ); diff --git a/packages/sdk/src/cli/query/index.ts b/packages/sdk/src/cli/query/index.ts index 40ce7b24d..d6fd551dc 100644 --- a/packages/sdk/src/cli/query/index.ts +++ b/packages/sdk/src/cli/query/index.ts @@ -41,6 +41,7 @@ import type { Application } from "@tailor-platform/tailor-proto/application_reso export type { QueryEngine } from "./types"; const queryEngineSchema = z.enum(queryEngines); +// strip unknown keys const queryBaseOptionsSchema = z.object({ workspaceId: z.string().optional(), profile: z.string().optional(), @@ -152,7 +153,7 @@ async function loadOptions(options: QueryBaseOptions) { }); if (!machineUser) { throw new Error( - "Machine user is required. Specify --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor-sdk profile update --machine-user '.", + "Machine user is required. Specify --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor profile update --machine-user '.", ); } @@ -212,10 +213,10 @@ async function sqlQuery( workspaceId: args.workspaceId, name: `query-sql-${args.namespace}.js`, code: args.bundledCode, - arg: JSON.stringify({ + arg: { namespace: args.namespace, queries, - }), + }, invoker, }); @@ -253,11 +254,11 @@ async function gqlQuery( workspaceId: args.workspaceId, name: `query-gql.js`, code: args.bundledCode, - arg: JSON.stringify({ + arg: { endpoint: `${application.url}/query`, accessToken, query: args.query, - }), + }, invoker, }); @@ -748,7 +749,7 @@ export const queryCommand = defineAppCommand({ name: "query", description: "Run SQL/GraphQL query.", args: z - .object({ + .strictObject({ ...deploymentArgs, engine: arg(queryEngineSchema, { description: "Query engine (sql or gql)", @@ -766,7 +767,6 @@ export const queryCommand = defineAppCommand({ }), "machine-user": arg(z.string().optional(), { alias: "m", - hiddenAlias: "machineuser", description: "Machine user name for query execution. Falls back to the active profile's default machine user.", env: "TAILOR_PLATFORM_MACHINE_USER_NAME", @@ -800,8 +800,7 @@ export const queryCommand = defineAppCommand({ message: "Pass only one of --edit, -q/--query, or -f/--file.", }); } - }) - .strict(), + }), run: async (args) => { const mode = await resolveQueryCommandInput({ query: args.query, @@ -826,9 +825,7 @@ export const queryCommand = defineAppCommand({ if (mode.mode === "repl") { const newlineOnEnter = - args["newline-on-enter"] ?? - parseBoolean(process.env.TAILOR_PLATFORM_QUERY_NEWLINE_ON_ENTER) ?? - true; + args["newline-on-enter"] ?? parseBoolean(process.env.TAILOR_QUERY_NEWLINE_ON_ENTER) ?? true; await runRepl({ ...sharedOptions, json: args.json, diff --git a/packages/sdk/src/cli/query/type-field-order.test.ts b/packages/sdk/src/cli/query/type-field-order.test.ts new file mode 100644 index 000000000..4819e2f48 --- /dev/null +++ b/packages/sdk/src/cli/query/type-field-order.test.ts @@ -0,0 +1,51 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { afterEach, describe, expect, test } from "vitest"; +import { loadTypeFieldOrder } from "./type-field-order"; +import type { LoadedConfig } from "../shared/config-loader"; + +describe("loadTypeFieldOrder", () => { + let tmpDir: string | undefined; + + afterEach(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + function writeTypeFile(name: string, source: string): string { + if (!tmpDir) { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(import.meta.dirname, ".type-order-"))); + } + const file = path.join(tmpDir, name); + fs.writeFileSync(file, source); + return file; + } + + test("loads field order from db.table builder outputs", async () => { + const typeFile = writeTypeFile( + "user.ts", + ` +import { db } from "@tailor-platform/sdk"; +export const user = db.table("User", { + firstName: db.string(), + lastName: db.string(), +}); +`, + ); + const config = { + path: "tailor.config.ts", + name: "sample-app", + db: { + main: { + files: [typeFile], + }, + }, + } as LoadedConfig; + + await expect(loadTypeFieldOrder(config, "main")).resolves.toEqual( + new Map([["User", ["id", "firstName", "lastName"]]]), + ); + }); +}); diff --git a/packages/sdk/src/cli/query/type-field-order.ts b/packages/sdk/src/cli/query/type-field-order.ts index 04a7c9265..29674e214 100644 --- a/packages/sdk/src/cli/query/type-field-order.ts +++ b/packages/sdk/src/cli/query/type-field-order.ts @@ -1,6 +1,7 @@ import { pathToFileURL } from "node:url"; import * as path from "pathe"; import { loadFilesWithIgnores } from "#/cli/services/file-loader"; +import { stripTailorDBTypeBuilderHelpers } from "#/parser/service/tailordb/builder-helpers"; import { TailorDBTypeSchema } from "#/parser/service/tailordb/index"; import type { LoadedConfig } from "#/cli/shared/config-loader"; @@ -32,7 +33,9 @@ export async function loadTypeFieldOrder( const module = await import(pathToFileURL(typeFile).href); for (const exportedValue of Object.values(module)) { - const result = TailorDBTypeSchema.safeParse(exportedValue); + const result = TailorDBTypeSchema.safeParse( + stripTailorDBTypeBuilderHelpers(exportedValue), + ); if (!result.success) { continue; } diff --git a/packages/sdk/src/cli/services/application.test.ts b/packages/sdk/src/cli/services/application.test.ts index ddb2df888..83c4c5dd1 100644 --- a/packages/sdk/src/cli/services/application.test.ts +++ b/packages/sdk/src/cli/services/application.test.ts @@ -1,12 +1,14 @@ import { describe, expect, test } from "vitest"; import { defineConfig } from "#/configure/config/index"; import { defineAuth } from "#/configure/services/auth/index"; +import { defineIdp } from "#/configure/services/idp/index"; +import { defineStaticWebSite } from "#/configure/services/staticwebsite/index"; import { db } from "#/configure/services/tailordb/schema"; import { defineApplication } from "./application"; describe("defineAuth parse wiring", () => { test("preserves an explicit userProfile.namespace through AuthConfigSchema.parse", async () => { - const userType = db.type("User", { + const userType = db.table("User", { email: db.string().unique(), role: db.string(), }); @@ -39,4 +41,42 @@ describe("defineAuth parse wiring", () => { expect(application.authService!.userProfile?.namespace).toBe("external-ns"); }); + + test("accepts defineIdp helper objects when parsing IdP services", () => { + const idp = defineIdp("my-idp", { + clients: ["default-client"], + }); + + const config = { + ...defineConfig({ + name: "testApp", + idp: [idp], + }), + path: "tailor.config.ts", + }; + + const application = defineApplication({ config }); + + expect(application.idpServices).toHaveLength(1); + expect(application.idpServices[0]?.name).toBe("my-idp"); + }); + + test("accepts defineStaticWebSite helper objects when parsing static websites", () => { + const website = defineStaticWebSite("my-site", { + description: "my website", + }); + + const config = { + ...defineConfig({ + name: "testApp", + staticWebsites: [website], + }), + path: "tailor.config.ts", + }; + + const application = defineApplication({ config }); + + expect(application.staticWebsiteServices).toHaveLength(1); + expect(application.staticWebsiteServices[0]?.name).toBe("my-site"); + }); }); diff --git a/packages/sdk/src/cli/services/application.ts b/packages/sdk/src/cli/services/application.ts index 20ede59a9..712fccfb3 100644 --- a/packages/sdk/src/cli/services/application.ts +++ b/packages/sdk/src/cli/services/application.ts @@ -19,13 +19,14 @@ import { createTailorDBService, type TailorDBService } from "#/cli/services/tail import { assertUniqueLocalTailorDBTypeNames } from "#/cli/services/tailordb/type-name-validation"; import { bundleWorkflowJobs, type BundleWorkflowJobsResult } from "#/cli/services/workflow/bundler"; import { createWorkflowService, type WorkflowService } from "#/cli/services/workflow/service"; +import { getApplicationAuthNamespace } from "#/cli/shared/auth-namespace"; import { resolveBundleLogLevel } from "#/cli/shared/bundle-log-level"; import { type LoadedConfig } from "#/cli/shared/config-loader"; import { getDistDir } from "#/cli/shared/dist-dir"; import { resolveInlineSourcemap } from "#/cli/shared/inline-sourcemap"; import { logger } from "#/cli/shared/logger"; import { resolverBundleKey } from "#/cli/shared/resolver-bundle-key"; -import { buildTriggerContext } from "#/cli/shared/trigger-context"; +import { buildStartContext } from "#/cli/shared/start-context"; import { type AppConfig, type ExecutorServiceInput, @@ -34,7 +35,7 @@ import { type WorkflowServiceConfig, } from "#/configure/config/types"; import { type AuthConfig } from "#/configure/services/auth/types"; -import { type IdPConfig } from "#/configure/services/idp/types"; +import { type IdPConfig, type IdPOwnConfig } from "#/configure/services/idp/types"; import { AIGatewaySchema } from "#/parser/service/aigateway/index"; import { AuthConfigSchema } from "#/parser/service/auth/index"; import { IdPSchema } from "#/parser/service/idp/index"; @@ -160,6 +161,13 @@ type DefineIdpResult = { subgraphs: Array<{ Type: string; Name: string }>; }; +function stripIdpProviderHelper(idpConfig: IdPOwnConfig): IdPOwnConfig { + const configWithProvider = idpConfig as IdPOwnConfig & { provider?: unknown }; + if (typeof configWithProvider.provider !== "function") return idpConfig; + const { provider: _provider, ...config } = configWithProvider; + return config as IdPOwnConfig; +} + function defineIdp(config: readonly IdPConfig[] | undefined): DefineIdpResult { const idpServices: IdP[] = []; const subgraphs: Array<{ Type: string; Name: string }> = []; @@ -176,7 +184,7 @@ function defineIdp(config: readonly IdPConfig[] | undefined): DefineIdpResult { } idpNames.add(name); if (!("external" in idpConfig)) { - const idp = IdPSchema.parse(idpConfig); + const idp = IdPSchema.parse(stripIdpProviderHelper(idpConfig)); idpServices.push(idp); } subgraphs.push({ Type: "idp", Name: name }); @@ -245,6 +253,13 @@ function defineHttpAdapterService( return createHttpAdapterService({ config, baseDir }); } +function stripStaticWebsiteUrlHelper(config: StaticWebsiteInput): StaticWebsiteInput { + const configWithUrl = config as StaticWebsiteInput & { url?: unknown }; + if (configWithUrl.url !== `${config.name}:url`) return config; + const { url: _url, ...websiteConfig } = configWithUrl; + return websiteConfig; +} + function defineStaticWebsites( websites: readonly StaticWebsiteInput[] | undefined, ): StaticWebsite[] { @@ -252,7 +267,7 @@ function defineStaticWebsites( const websiteNames = new Set(); (websites ?? []).forEach((config) => { - const website = StaticWebsiteSchema.parse(config); + const website = StaticWebsiteSchema.parse(stripStaticWebsiteUrlHelper(config)); if (websiteNames.has(website.name)) { throw new Error(`Static website with name "${website.name}" already defined.`); } @@ -527,10 +542,10 @@ export async function loadApplication( await httpAdapterService.loadAdapters(); } - // 7. Build trigger context for workflow/job trigger transformation - const triggerContext = await buildTriggerContext( + // 7. Build start context for workflow/job start transformation + const startContext = await buildStartContext( config.workflow, - authResult.authService?.config.name, + getApplicationAuthNamespace({ authService: authResult.authService, config }), baseDir, ); @@ -552,7 +567,7 @@ export async function loadApplication( pipeline.namespace, pipeline.config, baseDir, - triggerContext, + startContext, bundleCache, inlineSourcemap, bundleLogLevel, @@ -566,7 +581,7 @@ export async function loadApplication( if (executorService) { bundledScripts.executors = await bundleExecutors({ config: executorService.config, - triggerContext, + startContext, additionalFiles: [...pluginExecutorFiles], cache: bundleCache, inlineSourcemap, @@ -583,7 +598,7 @@ export async function loadApplication( workflowService.jobs, mainJobNames, config.env ?? {}, - triggerContext, + startContext, baseDir, bundleCache, inlineSourcemap, @@ -616,7 +631,7 @@ export async function loadApplication( authName, handlerAccessPath: `auth.hooks.beforeLogin.handler`, env: config.env ?? {}, - triggerContext, + startContext, cache: bundleCache, inlineSourcemap, bundleLogLevel, diff --git a/packages/sdk/src/cli/services/auth/bundler.test.ts b/packages/sdk/src/cli/services/auth/bundler.test.ts index 2e0d21d24..8b4904b31 100644 --- a/packages/sdk/src/cli/services/auth/bundler.test.ts +++ b/packages/sdk/src/cli/services/auth/bundler.test.ts @@ -75,12 +75,12 @@ export default { expect(code).toContain("env"); }); - test("inlines LOG_LEVEL references from config during bundling", async () => { + test("inlines TAILOR_APP_LOG_LEVEL references from config during bundling", async () => { const configFile = writeConfig(` const handler = async () => ({ ok: true }); export default { - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", auth: { hooks: { beforeLogin: { handler } } }, }; `); @@ -95,7 +95,7 @@ export default { const code = bundled.get("auth-hook--my-auth--before-login"); expect(code).toBeDefined(); - expect(code).not.toContain("process.env.LOG_LEVEL"); + expect(code).not.toContain("process.env.TAILOR_APP_LOG_LEVEL"); }); test("uses the passed baseDir rather than deriving one from the config file's own directory", async () => { diff --git a/packages/sdk/src/cli/services/auth/bundler.ts b/packages/sdk/src/cli/services/auth/bundler.ts index 0ef0bb441..378765b4e 100644 --- a/packages/sdk/src/cli/services/auth/bundler.ts +++ b/packages/sdk/src/cli/services/auth/bundler.ts @@ -1,13 +1,13 @@ import * as path from "pathe"; import * as rolldown from "rolldown"; import { computeBundlerContextHash, withCache, type BundleCache } from "#/cli/cache/bundle-cache"; -import { createTriggerTransformPlugin } from "#/cli/services/workflow/trigger-transformer"; +import { createStartTransformPlugin } from "#/cli/services/workflow/start-transformer"; import { createLogLevelTreeshakeOptions } from "#/cli/shared/bundle-log-level"; import { composeFunctionTreeshakeOptions } from "#/cli/shared/function-treeshake"; import { logger, styles } from "#/cli/shared/logger"; import { platformBundleDefinePlugin } from "#/cli/shared/platform-bundle-plugin"; import { resolveTSConfigWithFallback } from "#/cli/shared/resolve-tsconfig"; -import { serializeTriggerContext, type TriggerContext } from "#/cli/shared/trigger-context"; +import { serializeStartContext, type StartContext } from "#/cli/shared/start-context"; import { createVirtualEntry } from "#/cli/shared/virtual-entry"; import ml from "#/utils/multiline"; import type { LogLevel } from "#/configure/config/types"; @@ -24,8 +24,8 @@ export interface BundleAuthHooksOptions { handlerAccessPath: string; /** Environment variables to inject into the hook args */ env?: Record; - /** Trigger context for workflow/job transformations */ - triggerContext?: TriggerContext; + /** Start context for workflow/job transformations */ + startContext?: StartContext; /** Optional bundle cache for skipping unchanged builds */ cache?: BundleCache; /** Whether to enable inline sourcemaps */ @@ -53,7 +53,7 @@ export async function bundleAuthHooks( authName, handlerAccessPath, env = {}, - triggerContext, + startContext, cache, inlineSourcemap, bundleLogLevel = "DEBUG", @@ -69,7 +69,7 @@ export async function bundleAuthHooks( const functionName = `auth-hook--${authName}--before-login`; - const serializedTriggerContext = serializeTriggerContext(triggerContext); + const serializedStartContext = serializeStartContext(startContext); // Include sorted env variables as a prefix so that env changes invalidate the cache const sortedEnvPrefix = JSON.stringify( @@ -77,7 +77,7 @@ export async function bundleAuthHooks( ); const contextHash = computeBundlerContextHash({ sourceFile: absoluteConfigPath, - serializedTriggerContext, + extraContext: serializedStartContext, tsconfig, inlineSourcemap, bundleLogLevel, @@ -101,10 +101,10 @@ export async function bundleAuthHooks( `; const entry = createVirtualEntry(`auth-hook:${functionName}`, entryContent); - const triggerPlugin = createTriggerTransformPlugin(triggerContext); + const startPlugin = createStartTransformPlugin(startContext); const plugins: rolldown.Plugin[] = [entry.plugin]; - if (triggerPlugin) { - plugins.push(triggerPlugin); + if (startPlugin) { + plugins.push(startPlugin); } plugins.push(platformBundleDefinePlugin, ...cachePlugins); @@ -127,7 +127,7 @@ export async function bundleAuthHooks( plugins, transform: { define: { - "process.env.LOG_LEVEL": JSON.stringify(bundleLogLevel), + "process.env.TAILOR_APP_LOG_LEVEL": JSON.stringify(bundleLogLevel), }, }, treeshake: composeFunctionTreeshakeOptions([ diff --git a/packages/sdk/src/cli/services/executor/bundler.ts b/packages/sdk/src/cli/services/executor/bundler.ts index ec003444c..f6fcda067 100644 --- a/packages/sdk/src/cli/services/executor/bundler.ts +++ b/packages/sdk/src/cli/services/executor/bundler.ts @@ -2,7 +2,7 @@ import * as path from "pathe"; import * as rolldown from "rolldown"; import { computeBundlerContextHash, withCache, type BundleCache } from "#/cli/cache/bundle-cache"; import { loadFilesWithIgnores, type FileLoadConfig } from "#/cli/services/file-loader"; -import { createTriggerTransformPlugin } from "#/cli/services/workflow/trigger-transformer"; +import { createStartTransformPlugin } from "#/cli/services/workflow/start-transformer"; import { withBundleConcurrency } from "#/cli/shared/bundle-concurrency"; import { createLogLevelTreeshakeOptions } from "#/cli/shared/bundle-log-level"; import { composeFunctionTreeshakeOptions } from "#/cli/shared/function-treeshake"; @@ -10,7 +10,7 @@ import { logger, styles } from "#/cli/shared/logger"; import { platformBundleDefinePlugin } from "#/cli/shared/platform-bundle-plugin"; import { resolveTSConfigWithFallback } from "#/cli/shared/resolve-tsconfig"; import { INVOKER_EXPR } from "#/cli/shared/runtime-exprs"; -import { serializeTriggerContext, type TriggerContext } from "#/cli/shared/trigger-context"; +import { serializeStartContext, type StartContext } from "#/cli/shared/start-context"; import { createVirtualEntry } from "#/cli/shared/virtual-entry"; import ml from "#/utils/multiline"; import { loadExecutor } from "./loader"; @@ -27,8 +27,8 @@ interface ExecutorInfo { export interface BundleExecutorsOptions { /** Executor file loading configuration */ config: FileLoadConfig; - /** Trigger context for workflow/job transformations */ - triggerContext?: TriggerContext; + /** Start context for workflow/job transformations */ + startContext?: StartContext; /** Additional files to bundle (e.g., plugin-generated executors) */ additionalFiles?: string[]; /** Optional bundle cache for skipping unchanged builds */ @@ -56,7 +56,7 @@ export async function bundleExecutors( const bundledCode = new Map(); const { config, - triggerContext, + startContext, additionalFiles = [], cache, inlineSourcemap, @@ -106,14 +106,7 @@ export async function bundleExecutors( // Process each executor, capped by TAILOR_BUNDLE_CONCURRENCY to bound native // memory use (each rolldown.build allocates its own module graph). const results = await withBundleConcurrency(executors, (executor) => - bundleSingleExecutor( - executor, - tsconfig, - triggerContext, - cache, - inlineSourcemap, - bundleLogLevel, - ), + bundleSingleExecutor(executor, tsconfig, startContext, cache, inlineSourcemap, bundleLogLevel), ); for (const [name, code] of results) { @@ -128,16 +121,16 @@ export async function bundleExecutors( async function bundleSingleExecutor( executor: ExecutorInfo, tsconfig: string | undefined, - triggerContext?: TriggerContext, + startContext?: StartContext, cache?: BundleCache, inlineSourcemap?: boolean, bundleLogLevel: LogLevel = "DEBUG", ): Promise<[string, string]> { - const serializedTriggerContext = serializeTriggerContext(triggerContext); + const serializedStartContext = serializeStartContext(startContext); const contextHash = computeBundlerContextHash({ sourceFile: executor.sourceFile, - serializedTriggerContext, + extraContext: serializedStartContext, tsconfig, inlineSourcemap, bundleLogLevel, @@ -164,10 +157,10 @@ async function bundleSingleExecutor( `; const entry = createVirtualEntry(`executor:${executor.name}`, entryContent); - const triggerPlugin = createTriggerTransformPlugin(triggerContext); + const startPlugin = createStartTransformPlugin(startContext); const plugins: rolldown.Plugin[] = [entry.plugin]; - if (triggerPlugin) { - plugins.push(triggerPlugin); + if (startPlugin) { + plugins.push(startPlugin); } plugins.push(platformBundleDefinePlugin, ...cachePlugins); diff --git a/packages/sdk/src/cli/services/executor/loader.test.ts b/packages/sdk/src/cli/services/executor/loader.test.ts new file mode 100644 index 000000000..689e068ee --- /dev/null +++ b/packages/sdk/src/cli/services/executor/loader.test.ts @@ -0,0 +1,32 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { describe, expect, test } from "vitest"; +import { tempCwd } from "#/cli/shared/test-helpers/temp-cwd"; +import { loadExecutor } from "./loader"; + +describe("loadExecutor", () => { + test("accepts executor trigger helper args", async () => { + using tmp = tempCwd("sdk-executor-loader-"); + const executorFile = path.join(tmp.dir, "executor.ts"); + fs.writeFileSync( + executorFile, + ` +import { createExecutor, scheduleTrigger } from "@tailor-platform/sdk"; + +export default createExecutor({ + name: "daily", + trigger: scheduleTrigger({ cron: "0 12 * * *" }), + operation: { + kind: "function", + body: async () => {}, + }, +}); +`, + ); + + const executor = await loadExecutor(executorFile); + + expect(executor).not.toBeNull(); + expect(executor?.trigger).not.toHaveProperty("__args"); + }); +}); diff --git a/packages/sdk/src/cli/services/executor/loader.ts b/packages/sdk/src/cli/services/executor/loader.ts index 2f2a8f30d..a47351cfe 100644 --- a/packages/sdk/src/cli/services/executor/loader.ts +++ b/packages/sdk/src/cli/services/executor/loader.ts @@ -1,7 +1,22 @@ import { pathToFileURL } from "node:url"; import { ExecutorSchema } from "#/parser/service/executor/index"; +import { isSdkBranded } from "#/utils/brand"; import type { Executor } from "#/types/executor.generated"; +export function stripExecutorTriggerArgs(executor: unknown): unknown { + if (!isSdkBranded(executor, "executor")) { + return executor; + } + + const trigger = (executor as { trigger?: unknown }).trigger; + if (trigger === null || typeof trigger !== "object" || !("__args" in trigger)) { + return executor; + } + + const { __args: _args, ...triggerConfig } = trigger as Record; + return { ...(executor as Record), trigger: triggerConfig }; +} + /** * Load and validate an executor definition from a file. * @param executorFilePath - Path to the executor file @@ -11,7 +26,7 @@ export async function loadExecutor(executorFilePath: string): Promise => { try { const executorModule = await import(pathToFileURL(executorFile).href); - const result = ExecutorSchema.safeParse(executorModule.default); + const result = ExecutorSchema.safeParse(stripExecutorTriggerArgs(executorModule.default)); if (result.success) { const relativePath = path.relative(process.cwd(), executorFile); logger.log( @@ -111,7 +112,7 @@ export function createExecutorService(params: CreateExecutorServiceParams): Exec const executor = await loadExecutorForFile(filePath); if (executor) { // Track as plugin executor (plugin ID is extracted from file path) - // File path format: .tailor-sdk/plugin/{executor-name}.ts + // File path format: .tailor/plugin/{executor-name}.ts pluginExecutors.push({ executor, pluginId: "plugin-generated", diff --git a/packages/sdk/src/cli/services/http-adapter/bundler.ts b/packages/sdk/src/cli/services/http-adapter/bundler.ts index 095b6411f..4e0116fbd 100644 --- a/packages/sdk/src/cli/services/http-adapter/bundler.ts +++ b/packages/sdk/src/cli/services/http-adapter/bundler.ts @@ -92,7 +92,7 @@ async function bundleAdapterScript( ): Promise<[string, "input" | "output", string]> { const contextHash = computeBundlerContextHash({ sourceFile: adapter.sourceFile, - serializedTriggerContext: kind === "input" ? adapter.methods.join(",") : "", + extraContext: kind === "input" ? adapter.methods.join(",") : "", tsconfig, inlineSourcemap: false, bundleLogLevel, diff --git a/packages/sdk/src/cli/services/resolver/bundler.ts b/packages/sdk/src/cli/services/resolver/bundler.ts index fbaa6fd55..34d364b61 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.ts @@ -2,7 +2,7 @@ import * as path from "pathe"; import * as rolldown from "rolldown"; import { type BundleCache, computeBundlerContextHash, withCache } from "#/cli/cache/bundle-cache"; import { type FileLoadConfig, loadFilesWithIgnores } from "#/cli/services/file-loader"; -import { createTriggerTransformPlugin } from "#/cli/services/workflow/trigger-transformer"; +import { createStartTransformPlugin } from "#/cli/services/workflow/start-transformer"; import { withBundleConcurrency } from "#/cli/shared/bundle-concurrency"; import { createLogLevelTreeshakeOptions } from "#/cli/shared/bundle-log-level"; import { composeFunctionTreeshakeOptions } from "#/cli/shared/function-treeshake"; @@ -10,7 +10,7 @@ import { logger, styles } from "#/cli/shared/logger"; import { platformBundleDefinePlugin } from "#/cli/shared/platform-bundle-plugin"; import { resolveTSConfigWithFallback } from "#/cli/shared/resolve-tsconfig"; import { INVOKER_EXPR } from "#/cli/shared/runtime-exprs"; -import { serializeTriggerContext, type TriggerContext } from "#/cli/shared/trigger-context"; +import { serializeStartContext, type StartContext } from "#/cli/shared/start-context"; import { createVirtualEntry } from "#/cli/shared/virtual-entry"; import ml from "#/utils/multiline"; import { loadResolver } from "./loader"; @@ -31,7 +31,7 @@ interface ResolverInfo { * @param namespace - Resolver namespace name * @param config - Resolver file loading configuration * @param baseDir - Directory the config's file patterns are resolved against - * @param triggerContext - Trigger context for workflow/job transformations + * @param startContext - Start context for workflow/job transformations * @param cache - Optional bundle cache for skipping unchanged builds * @param inlineSourcemap - Whether to enable inline sourcemaps * @param bundleLogLevel - Controls which console calls are kept in bundled code @@ -41,7 +41,7 @@ export async function bundleResolvers( namespace: string, config: FileLoadConfig, baseDir: string, - triggerContext?: TriggerContext, + startContext?: StartContext, cache?: BundleCache, inlineSourcemap?: boolean, bundleLogLevel: LogLevel = "DEBUG", @@ -83,7 +83,7 @@ export async function bundleResolvers( namespace, resolver, tsconfig, - triggerContext, + startContext, cache, inlineSourcemap, bundleLogLevel, @@ -103,16 +103,16 @@ async function bundleSingleResolver( namespace: string, resolver: ResolverInfo, tsconfig: string | undefined, - triggerContext?: TriggerContext, + startContext?: StartContext, cache?: BundleCache, inlineSourcemap?: boolean, bundleLogLevel: LogLevel = "DEBUG", ): Promise<[string, string]> { - const serializedTriggerContext = serializeTriggerContext(triggerContext); + const serializedStartContext = serializeStartContext(startContext); const contextHash = computeBundlerContextHash({ sourceFile: resolver.sourceFile, - serializedTriggerContext, + extraContext: serializedStartContext, tsconfig, inlineSourcemap, bundleLogLevel, @@ -138,7 +138,7 @@ async function bundleSingleResolver( const result = t.object(_internalResolver.input).parse({ value: context.input, data: context.input, - user: context.user, + invoker, }); if (result.issues) { @@ -156,10 +156,10 @@ async function bundleSingleResolver( `; const entry = createVirtualEntry(`resolver:${resolver.name}`, entryContent); - const triggerPlugin = createTriggerTransformPlugin(triggerContext); + const startPlugin = createStartTransformPlugin(startContext); const plugins: rolldown.Plugin[] = [entry.plugin]; - if (triggerPlugin) { - plugins.push(triggerPlugin); + if (startPlugin) { + plugins.push(startPlugin); } plugins.push(platformBundleDefinePlugin, ...cachePlugins); diff --git a/packages/sdk/src/cli/services/service-brand.test.ts b/packages/sdk/src/cli/services/service-brand.test.ts index cc195c9c4..53a3fc7bd 100644 --- a/packages/sdk/src/cli/services/service-brand.test.ts +++ b/packages/sdk/src/cli/services/service-brand.test.ts @@ -150,7 +150,7 @@ describe("service brand-based error categorization", () => { "workflow", { name: "approvalFlow", - mainJob: { name: "startApproval", trigger: () => {}, body: () => {} }, + mainJob: { name: "startApproval", start: () => {}, body: () => {} }, }, { name: "approvalFlow" }, "approvalFlow", @@ -162,7 +162,7 @@ describe("service brand-based error categorization", () => { itLoadsBrandedValues( WorkflowJobSchema, "workflow-job", - { name: "processOrder", trigger: () => {}, body: () => {} }, + { name: "processOrder", start: () => {}, body: () => {} }, { name: "processOrder", body: "not a function" }, "processOrder", { helperFn: () => {} }, diff --git a/packages/sdk/src/cli/services/stale-cleanup.ts b/packages/sdk/src/cli/services/stale-cleanup.ts deleted file mode 100644 index 66717e81c..000000000 --- a/packages/sdk/src/cli/services/stale-cleanup.ts +++ /dev/null @@ -1,50 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as path from "pathe"; - -const legacyBundleDirectories: ReadonlyArray<{ - name: string; - suffixes: readonly string[]; -}> = [ - { name: "resolvers", suffixes: [".entry.js"] }, - { name: "executors", suffixes: [".entry.js"] }, - { name: "workflow-jobs", suffixes: [".js", ".js.map"] }, - { name: "auth-hooks", suffixes: [".entry.js"] }, - { name: "http-adapters", suffixes: [".entry.js"] }, -]; - -/** - * Remove bundle artifacts created by SDK versions that used disk-backed entries. - * - * Concurrent callers are safe because current bundlers no longer create files - * in these directories and each removal uses `force: true`. - * @param outputRoot - SDK output directory - */ -export async function removeLegacyBundleFiles(outputRoot: string): Promise { - await Promise.all([ - ...legacyBundleDirectories.map(({ name, suffixes }) => - removeMatchingFiles(path.join(outputRoot, name), suffixes), - ), - fs.rm(path.join(outputRoot, "hooks-validate-scripts"), { - recursive: true, - force: true, - }), - ]); -} - -async function removeMatchingFiles(outputDir: string, suffixes: readonly string[]): Promise { - let files: string[]; - try { - files = await fs.readdir(outputDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return; - } - throw error; - } - - for (const file of files) { - if (suffixes.some((suffix) => file.endsWith(suffix))) { - await fs.rm(path.join(outputDir, file), { force: true }); - } - } -} diff --git a/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.test.ts b/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.test.ts index fd40fa685..d50b3553b 100644 --- a/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.test.ts +++ b/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.test.ts @@ -78,6 +78,7 @@ describe("precompileTailorDBTypeScripts", () => { metadata: { hooks: { create: createHook } }, }, }, + metadata: {}, } as unknown as TailorDBTypeRaw; try { @@ -103,6 +104,7 @@ describe("precompileTailorDBTypeScripts", () => { metadata: { hooks: { create: createHook } }, }, }, + metadata: {}, } as unknown as TailorDBTypeRaw; expectVirtualEntry = true; @@ -216,7 +218,7 @@ describe("collectSourceBindings", () => { `import { db } from "@tailor-platform/sdk";`, `import { formatAddress } from "./helpers";`, `const MAX = 100;`, - `export const customer = db.type("Customer", {`, + `export const customer = db.table("Customer", {`, ` name: db.string(),`, `}).hooks({ fullAddress: { create: ({ data }) => formatAddress(data) } });`, ``, diff --git a/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts b/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts index c8a13076e..b02d65aef 100644 --- a/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts +++ b/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts @@ -4,7 +4,7 @@ import { resolve } from "pathe"; import * as rolldown from "rolldown"; import { platformBundleDefinePlugin } from "#/cli/shared/platform-bundle-plugin"; import { createVirtualEntry } from "#/cli/shared/virtual-entry"; -import { stringifyFunction, tailorUserMap } from "#/parser/service/tailordb/field"; +import { stringifyFunction, tailorPrincipalMap } from "#/parser/service/tailordb/field"; import { setPrecompiledScriptExpr } from "#/parser/service/tailordb/hooks-validate-precompiled-expr"; import { assertDefined } from "#/utils/assert"; import { assertParsableExpression } from "#/utils/script-expr"; @@ -24,7 +24,7 @@ type ScriptFunction = (...args: unknown[]) => unknown; type ScriptTarget = { fn: ScriptFunction; - kind: "hooks" | "validate"; + kind: "hooks.create" | "hooks.update" | "validate" | "typeHook" | "typeValidate"; }; /** Binding found in the source file: either an import or a top-level declaration */ @@ -97,21 +97,16 @@ function collectScriptTargets(type: TailorDBTypeSchemaOutput): ScriptTarget[] { const createHook = toScriptFunction(metadata.hooks?.create); if (createHook) { - targets.push({ fn: createHook, kind: "hooks" }); + targets.push({ fn: createHook, kind: "hooks.create" }); } const updateHook = toScriptFunction(metadata.hooks?.update); if (updateHook) { - targets.push({ fn: updateHook, kind: "hooks" }); + targets.push({ fn: updateHook, kind: "hooks.update" }); } for (const validateInput of metadata.validate ?? []) { - if (typeof validateInput === "function") { - const validateFn = toScriptFunction(validateInput); - if (validateFn) targets.push({ fn: validateFn, kind: "validate" }); - } else { - const validateFn = toScriptFunction(validateInput[0]); - if (validateFn) targets.push({ fn: validateFn, kind: "validate" }); - } + const validateFn = toScriptFunction(validateInput); + if (validateFn) targets.push({ fn: validateFn, kind: "validate" }); } if (field.type === "nested" && field.fields) { @@ -125,6 +120,20 @@ function collectScriptTargets(type: TailorDBTypeSchemaOutput): ScriptTarget[] { collectFieldTargets(field); } + if (type.metadata.typeHook) { + for (const op of ["create", "update"] as const) { + const fn = toScriptFunction(type.metadata.typeHook[op]); + if (fn) { + targets.push({ fn, kind: "typeHook" }); + } + } + } + + const typeValidateFn = toScriptFunction(type.metadata.typeValidate); + if (typeValidateFn) { + targets.push({ fn: typeValidateFn, kind: "typeValidate" }); + } + return targets; } @@ -386,13 +395,13 @@ export function resolveNeededBindings( }; } -function buildPrecompiledExpr(bundleCode: string): string { +function buildPrecompiledExpr(bundleCode: string, argsObject: string): string { return ( "(() => {\n" + " const module = { exports: {} };\n" + " const exports = module.exports;\n" + `${bundleCode}\n` + - ` return module.exports.main({ value: _value, data: _data, user: ${tailorUserMap} });\n` + + ` return module.exports.main(${argsObject});\n` + "})()" ); } @@ -403,6 +412,7 @@ function buildPrecompiledExpr(bundleCode: string): string { * @param declarations - Declaration statement texts. * @param fnSource - The function source code. * @param sourceFilePath - Path to the source file for resolving relative imports. + * @param multiArg - Whether the function accepts multiple arguments (spread via `...args`). * @returns Entry file content string. */ export function buildMinimalEntryFromResolved( @@ -410,6 +420,7 @@ export function buildMinimalEntryFromResolved( declarations: string[], fnSource: string, sourceFilePath: string, + multiArg = false, ): string { const sourceDir = resolve(sourceFilePath, "..").replace(/\\/g, "/"); @@ -424,14 +435,16 @@ export function buildMinimalEntryFromResolved( const lines = [ ...resolvedImports, ...declarations, - `export function main(input) { return (${fnSource})(input); }`, + multiArg + ? `export function main(...args) { return (${fnSource})(...args); }` + : `export function main(input) { return (${fnSource})(input); }`, ]; return lines.join("\n"); } async function bundleScriptTarget(args: { fn: ScriptFunction; - kind: "hooks" | "validate"; + kind: "hooks.create" | "hooks.update" | "validate" | "typeHook" | "typeValidate"; sourceFilePath: string; sourceBindings: Map; typeName: string; @@ -441,10 +454,23 @@ async function bundleScriptTarget(args: { const { fn, kind, sourceFilePath, sourceBindings, typeName, targetIndex, tsconfig } = args; const context = `${kind} in ${sourceFilePath}`; const fnSource = stringifyFunction(fn); - const inlineExpr = assertParsableExpression( - `(${fnSource})({ value: _value, data: _data, user: ${tailorUserMap} })`, - context, - ); + if ((kind === "typeValidate" || kind === "validate") && fn.constructor.name === "AsyncFunction") { + throw new Error( + `${context} must be synchronous — the generated validator runs synchronously, ` + + "so issues reported after an await are silently lost. Remove the async keyword.", + ); + } + const argsObject = + kind === "hooks.create" + ? `{ input: _value, invoker: ${tailorPrincipalMap}, now: _now }` + : kind === "hooks.update" + ? `{ input: _value, oldValue: _oldValue, invoker: ${tailorPrincipalMap}, now: _now }` + : kind === "validate" + ? `{ value: _value }` + : kind === "typeHook" + ? `{ input: _input, oldRecord: _oldRecord, invoker: ${tailorPrincipalMap}, now: _now }` + : `{ newRecord: _newRecord, oldRecord: _oldRecord, invoker: ${tailorPrincipalMap} }, __issues`; + const inlineExpr = assertParsableExpression(`(${fnSource})(${argsObject})`, context); // Check if the function has free variables that need bundling const freeVars = findUndefinedReferences(`const __fn = ${fnSource};`); @@ -467,6 +493,7 @@ async function bundleScriptTarget(args: { declarations, fnSource, sourceFilePath, + kind === "typeValidate", ); const entry = createVirtualEntry( `tailordb-script:${typeName}:${targetIndex}`, @@ -494,7 +521,7 @@ async function bundleScriptTarget(args: { } as rolldown.BuildOptions); const bundledCode = buildResult.output[0].code; - return assertParsableExpression(buildPrecompiledExpr(bundledCode), context); + return assertParsableExpression(buildPrecompiledExpr(bundledCode, argsObject), context); } /** diff --git a/packages/sdk/src/cli/services/tailordb/service.test.ts b/packages/sdk/src/cli/services/tailordb/service.test.ts index f7f6ea536..bfa6ae545 100644 --- a/packages/sdk/src/cli/services/tailordb/service.test.ts +++ b/packages/sdk/src/cli/services/tailordb/service.test.ts @@ -37,7 +37,7 @@ describe("createTailorDBService.loadTypes", () => { "user.ts", ` import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const user = db.type("User", { +export const user = db.table("User", { name: db.string(), }).permission(unsafeAllowAllTypePermission).gqlPermission(unsafeAllowAllGqlPermission); `, @@ -46,7 +46,7 @@ export const user = db.type("User", { "account.ts", ` import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const account = db.type("User", { +export const account = db.table("User", { email: db.string(), }).permission(unsafeAllowAllTypePermission).gqlPermission(unsafeAllowAllGqlPermission); `, @@ -69,7 +69,7 @@ export const account = db.type("User", { "object-prototype.ts", ` import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const objectPrototype = db.type("toString", { +export const objectPrototype = db.table("toString", { value: db.string(), }).permission(unsafeAllowAllTypePermission).gqlPermission(unsafeAllowAllGqlPermission); `, @@ -91,7 +91,7 @@ export const objectPrototype = db.type("toString", { "proto.ts", ` import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const proto = db.type("__proto__", { +export const proto = db.table("__proto__", { value: db.string(), }).permission(unsafeAllowAllTypePermission).gqlPermission(unsafeAllowAllGqlPermission); `, @@ -114,7 +114,7 @@ export const proto = db.type("__proto__", { "overlapping-glob.ts", ` import { db, unsafeAllowAllGqlPermission, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const user = db.type("User", { +export const user = db.table("User", { name: db.string(), }).permission(unsafeAllowAllTypePermission).gqlPermission(unsafeAllowAllGqlPermission); `, @@ -139,7 +139,7 @@ export const user = db.type("User", { onNamespaceLoaded: () => ({ types: { auditLog: db - .type("AuditLog", { + .table("AuditLog", { message: db.string(), }) .permission(unsafeAllowAllTypePermission) @@ -169,7 +169,7 @@ export const user = db.type("User", { importPath: "@example/namespace", onNamespaceLoaded: () => ({ types: { - auditLog: db.type("AuditLogNoPermission", { + auditLog: db.table("AuditLogNoPermission", { message: db.string(), }), }, @@ -195,7 +195,7 @@ export const user = db.type("User", { "no-permission.ts", ` import { db, unsafeAllowAllGqlPermission } from "@tailor-platform/sdk"; -export const noPermission = db.type("NoPermission", { +export const noPermission = db.table("NoPermission", { name: db.string(), }).gqlPermission(unsafeAllowAllGqlPermission); `, @@ -220,7 +220,7 @@ export const noPermission = db.type("NoPermission", { "no-gql-permission.ts", ` import { db, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const noGqlPermission = db.type("NoGqlPermission", { +export const noGqlPermission = db.table("NoGqlPermission", { name: db.string(), }).permission(unsafeAllowAllTypePermission); `, @@ -243,7 +243,7 @@ export const noGqlPermission = db.type("NoGqlPermission", { "gql-disabled.ts", ` import { db, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const gqlDisabled = db.type("GqlDisabled", { +export const gqlDisabled = db.table("GqlDisabled", { name: db.string(), }).permission(unsafeAllowAllTypePermission).features({ gqlOperations: { create: false, update: false, delete: false, read: false }, @@ -267,7 +267,7 @@ export const gqlDisabled = db.type("GqlDisabled", { "namespace-gql-disabled.ts", ` import { db, unsafeAllowAllTypePermission } from "@tailor-platform/sdk"; -export const namespaceGqlDisabled = db.type("NamespaceGqlDisabled", { +export const namespaceGqlDisabled = db.table("NamespaceGqlDisabled", { name: db.string(), }).permission(unsafeAllowAllTypePermission); `, diff --git a/packages/sdk/src/cli/services/tailordb/service.ts b/packages/sdk/src/cli/services/tailordb/service.ts index 231080592..d282a188d 100644 --- a/packages/sdk/src/cli/services/tailordb/service.ts +++ b/packages/sdk/src/cli/services/tailordb/service.ts @@ -3,6 +3,7 @@ import * as path from "pathe"; import { loadFilesWithIgnores } from "#/cli/services/file-loader"; import { logger, styles } from "#/cli/shared/logger"; import { resolveTSConfigWithFallback } from "#/cli/shared/resolve-tsconfig"; +import { stripTailorDBTypeBuilderHelpers } from "#/parser/service/tailordb/builder-helpers"; import { parseTypes, TailorDBTypeSchema } from "#/parser/service/tailordb/index"; import { findMissingPermissionConfig, @@ -220,7 +221,7 @@ export function createTailorDBService(params: CreateTailorDBServiceParams): Tail for (const exportName of Object.keys(module)) { const exportedValue = module[exportName]; - const result = TailorDBTypeSchema.safeParse(exportedValue); + const result = TailorDBTypeSchema.safeParse(stripTailorDBTypeBuilderHelpers(exportedValue)); if (!result.success) { if (isSdkBranded(exportedValue, "tailordb-type")) { throw result.error; diff --git a/packages/sdk/src/cli/services/workflow/ast-transformer.test.ts b/packages/sdk/src/cli/services/workflow/ast-transformer.test.ts index 4255b20ec..c302b207a 100644 --- a/packages/sdk/src/cli/services/workflow/ast-transformer.test.ts +++ b/packages/sdk/src/cli/services/workflow/ast-transformer.test.ts @@ -4,13 +4,16 @@ import { describe, expect, test, vi } from "vitest"; import { logger } from "#/cli/shared/logger"; import { normalizeFilePath, - type TriggerContext, - type TriggerModuleBindings, - type TriggerTarget, -} from "#/cli/shared/trigger-context"; + type StartContext, + type StartModuleBindings, + type StartTarget, +} from "#/cli/shared/start-context"; import { findAllJobs } from "./job-detector"; import { transformWorkflowSource } from "./source-transformer"; -import { transformFunctionTriggers as transformFunctionTriggersWithContext } from "./trigger-transformer"; +import { + hasStartCall, + transformStartCalls as transformStartCallsWithContext, +} from "./start-transformer"; import { findAllWorkflows } from "./workflow-detector"; function parseProgram(source: string) { @@ -25,7 +28,7 @@ function findWorkflows(source: string) { return findAllWorkflows(parseProgram(source), source); } -function transformFunctionTriggers( +function transformStartCalls( source: string, workflowNameMap: Map, jobNameMap: Map, @@ -33,7 +36,7 @@ function transformFunctionTriggers( currentFilePath = path.resolve("test.ts"), authNamespace?: string, ) { - const localBindings = new Map(); + const localBindings = new Map(); for (const [name, jobName] of jobNameMap) { localBindings.set(name, { kind: "job", name: jobName }); } @@ -41,7 +44,7 @@ function transformFunctionTriggers( localBindings.set(name, { kind: "workflow", name: workflowName }); } - const modules = new Map([ + const modules = new Map([ [normalizeFilePath(currentFilePath), { localBindings, exports: new Map(localBindings) }], ]); for (const [file, workflowName] of workflowFileMap ?? []) { @@ -51,8 +54,8 @@ function transformFunctionTriggers( }); } - const context: TriggerContext = { modules, authNamespace }; - return transformFunctionTriggersWithContext(source, context, currentFilePath); + const context: StartContext = { modules, authNamespace }; + return transformStartCallsWithContext(source, context, currentFilePath); } function transformWorkflowJobSource( @@ -68,7 +71,7 @@ function transformWorkflowJobSource( targetJobExportName, otherJobExportNames, ); - return transformFunctionTriggers(stripped, new Map(), jobNameMap); + return transformStartCalls(stripped, new Map(), jobNameMap); } describe("AST Transformer - createWorkflowJob call detection", () => { @@ -85,7 +88,7 @@ const job1 = createWorkflowJob({ const job2 = createWorkflowJob({ name: "job-two", body: async (input, { env }) => { - return await job1.trigger(); + return await job1.start(); } }); `; @@ -379,7 +382,7 @@ const job = createWorkflowJob({ describe("AST Transformer - transformation logic", () => { describe("transformWorkflowSource", () => { - test("transforms trigger calls to triggerJobFunction", () => { + test("transforms start calls to startJobFunction", () => { const source = ` import { createWorkflowJob } from "@tailor-platform/sdk"; @@ -391,7 +394,7 @@ const fetchData = createWorkflowJob({ const mainJob = createWorkflowJob({ name: "main-job", body: async (input, { env }) => { - const result = await fetchData.trigger({ id: input.id }); + const result = await fetchData.start({ id: input.id }); return result; } }); @@ -408,14 +411,12 @@ const mainJob = createWorkflowJob({ allJobsMap, ); - expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("fetch-data", { id: input.id }))()', - ); + expect(result).toContain('tailor.workflow.startJobFunction("fetch-data", { id: input.id })'); // fetchData declaration is removed (const fetchData = ...) expect(result).not.toContain("const fetchData"); }); - test("forwards a second options argument to triggerJobFunction", () => { + test("forwards a second options argument to startJobFunction", () => { const source = ` import { createWorkflowJob } from "@tailor-platform/sdk"; @@ -427,7 +428,7 @@ const fetchData = createWorkflowJob({ const mainJob = createWorkflowJob({ name: "main-job", body: async (input) => { - return await fetchData.trigger({ id: input.id }, { executionPolicyKey: "premium" }); + return await fetchData.start({ id: input.id }, { executionPolicyKey: "premium" }); } }); `; @@ -444,7 +445,7 @@ const mainJob = createWorkflowJob({ ); expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("fetch-data", { id: input.id }, { executionPolicyKey: "premium" }))()', + 'tailor.workflow.startJobFunction("fetch-data", { id: input.id }, { executionPolicyKey: "premium" })', ); }); @@ -463,7 +464,7 @@ const heavyJob = createWorkflowJob({ const mainJob = createWorkflowJob({ name: "main-job", body: async (input, { env }) => { - const result = await heavyJob.trigger(); + const result = await heavyJob.start(); return { result: "main" }; } }); @@ -486,10 +487,8 @@ const mainJob = createWorkflowJob({ expect(result).not.toContain("getDB"); // mainJob body is preserved expect(result).toContain('result: "main"'); - // trigger is transformed (job name appears in triggerJobFunction call) - expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("heavy-job", undefined))()', - ); + // start is transformed (job name appears in startJobFunction call) + expect(result).toContain('tailor.workflow.startJobFunction("heavy-job", undefined)'); }); test("removes declarations of multiple other jobs", () => { @@ -509,8 +508,8 @@ const job2 = createWorkflowJob({ const mainJob = createWorkflowJob({ name: "main-job", body: async () => { - await job1.trigger(); - await job2.trigger(); + await job1.start(); + await job2.start(); return "main"; } }); @@ -535,16 +534,12 @@ const mainJob = createWorkflowJob({ expect(result).toContain('"main"'); // heavy code is removed (part of job1/job2 body) expect(result).not.toContain("heavy code"); - // triggers are transformed (job names appear in triggerJobFunction calls) - expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("job-one", undefined))()', - ); - expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("job-two", undefined))()', - ); + // starts are transformed (job names appear in startJobFunction calls) + expect(result).toContain('tailor.workflow.startJobFunction("job-one", undefined)'); + expect(result).toContain('tailor.workflow.startJobFunction("job-two", undefined)'); }); - test("does not transform trigger calls inside fallback-removed job bodies", () => { + test("does not transform start calls inside fallback-removed job bodies", () => { const source = ` import { createWorkflowJob } from "@tailor-platform/sdk"; @@ -556,7 +551,7 @@ const nestedJob = createWorkflowJob({ createWorkflowJob({ name: "fallback-job", body: async () => { - await nestedJob.trigger({ id: 1 }); + await nestedJob.start({ id: 1 }); return "fallback"; } }); @@ -569,11 +564,11 @@ const mainJob = createWorkflowJob({ const result = transformWorkflowSource(source, "main-job"); expect(result).toContain("body: () => {}"); - expect(result).not.toContain("nestedJob.trigger"); - expect(result).not.toContain("triggerJobFunction"); + expect(result).not.toContain("nestedJob.start"); + expect(result).not.toContain("startJobFunction"); }); - test("does not modify jobs without trigger calls", () => { + test("does not modify jobs without start calls", () => { const source = ` import { createWorkflowJob } from "@tailor-platform/sdk"; @@ -595,16 +590,16 @@ import { createWorkflowJob } from "@tailor-platform/sdk"; export const mainJob = createWorkflowJob({ name: "main-job", body: async () => { - const step = { trigger: async () => "local" }; - return await step.trigger(); + const step = { start: async () => "local" }; + return await step.start(); }, }); `; const result = transformWorkflowSource(source, "main-job", "mainJob", ["step"]); - expect(result).toContain('const step = { trigger: async () => "local" }'); - expect(result).toContain("step.trigger()"); + expect(result).toContain('const step = { start: async () => "local" }'); + expect(result).toContain("step.start()"); }); test("removes createWorkflow default export", () => { @@ -619,7 +614,7 @@ export const check_inventory = createWorkflowJob({ export const validate_order = createWorkflowJob({ name: "validate-order", body: (input) => { - const inventoryResult = check_inventory.trigger(); + const inventoryResult = check_inventory.start(); return { inventoryResult }; } }); @@ -763,47 +758,47 @@ const workflow2 = createWorkflow({ name: "workflow-two", mainJob: job2 }); }); }); -describe("AST Transformer - transformFunctionTriggers", () => { - describe("workflow trigger transformation", () => { - test("transforms workflow.trigger() calls to tailor.workflow.triggerWorkflow()", () => { +describe("AST Transformer - transformStartCalls", () => { + describe("workflow start transformation", () => { + test("transforms workflow.start() calls to tailor.workflow.startWorkflow()", () => { const source = ` -const workflowRunId = await orderWorkflow.trigger( +const workflowRunId = await orderWorkflow.start( { orderId: "123", customerId: "456" }, - { authInvoker: auth.invoker("admin") } + { invoker: "admin" } ); `; const workflowNameMap = new Map([["orderWorkflow", "order-processing"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); - expect(result).toContain('tailor.workflow.triggerWorkflow("order-processing"'); + expect(result).toContain('tailor.workflow.startWorkflow("order-processing"'); expect(result).toContain('{ orderId: "123", customerId: "456" }'); - expect(result).toContain('{ authInvoker: auth.invoker("admin") }'); + expect(result).toContain('{ invoker: "admin" }'); }); - test("transforms workflow.trigger() with shorthand authInvoker", () => { + test("transforms workflow.start() with shorthand invoker", () => { const source = ` -const authInvoker = auth.invoker("admin"); -const result = await myWorkflow.trigger({ id: 1 }, { authInvoker }); +const invoker = "admin"; +const result = await myWorkflow.start({ id: 1 }, { invoker }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); - expect(result).toContain('tailor.workflow.triggerWorkflow("my-workflow"'); - expect(result).toContain("{ authInvoker }"); + expect(result).toContain('tailor.workflow.startWorkflow("my-workflow"'); + expect(result).toContain("{ invoker }"); }); - test("transforms workflow.trigger() without options and omits the helper", () => { + test("transforms workflow.start() without options and omits the helper", () => { const source = ` -const result = await myWorkflow.trigger({ id: 1 }); +const result = await myWorkflow.start({ id: 1 }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers( + const result = transformStartCalls( source, workflowNameMap, jobNameMap, @@ -812,18 +807,18 @@ const result = await myWorkflow.trigger({ id: 1 }); "my-auth", ); - expect(result).toContain('tailor.workflow.triggerWorkflow("my-workflow", { id: 1 })'); - expect(result).not.toContain("__tailor_normalizeTriggerOptions"); + expect(result).toContain('tailor.workflow.startWorkflow("my-workflow", { id: 1 })'); + expect(result).not.toContain("__tailor_normalizeStartOptions"); }); - test("wraps a string-literal authInvoker with the runtime normalizer when authNamespace is provided", () => { + test("wraps a string-literal invoker with the runtime normalizer when authNamespace is provided", () => { const source = ` -const result = await myWorkflow.trigger({ id: 1 }, { authInvoker: "kiosk" }); +const result = await myWorkflow.start({ id: 1 }, { invoker: "kiosk" }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers( + const result = transformStartCalls( source, workflowNameMap, jobNameMap, @@ -832,23 +827,23 @@ const result = await myWorkflow.trigger({ id: 1 }, { authInvoker: "kiosk" }); "my-auth", ); - expect(result).toContain('tailor.workflow.triggerWorkflow("my-workflow"'); - expect(result).toContain('__tailor_normalizeTriggerOptions({ authInvoker: "kiosk" })'); + expect(result).toContain('tailor.workflow.startWorkflow("my-workflow"'); + expect(result).toContain('__tailor_normalizeStartOptions({ invoker: "kiosk" })'); // Helper injected at the top of the file with the namespace baked in expect(result).toContain( - 'const __tailor_normalizeTriggerOptions = (o) => o && typeof o.authInvoker === "string" ? { ...o, authInvoker: { namespace: "my-auth", machineUserName: o.authInvoker } } : o;', + 'const __tailor_normalizeStartOptions = (o) => { if (!o) return o; const { invoker, ...rest } = o; return typeof invoker === "string" ? { ...rest, authInvoker: { namespace: "my-auth", machineUserName: invoker } } : typeof invoker === "object" ? { ...rest, authInvoker: invoker } : o; };', ); }); - test("wraps a variable-reference authInvoker with the runtime normalizer", () => { + test("wraps a variable-reference invoker with the runtime normalizer", () => { const source = ` -const invoker = "kiosk"; -const result = await myWorkflow.trigger({ id: 1 }, { authInvoker: invoker }); +const machineUser = "kiosk"; +const result = await myWorkflow.start({ id: 1 }, { invoker: machineUser }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers( + const result = transformStartCalls( source, workflowNameMap, jobNameMap, @@ -857,18 +852,18 @@ const result = await myWorkflow.trigger({ id: 1 }, { authInvoker: invoker }); "my-auth", ); - expect(result).toContain("__tailor_normalizeTriggerOptions({ authInvoker: invoker })"); + expect(result).toContain("__tailor_normalizeStartOptions({ invoker: machineUser })"); }); - test("wraps a shorthand authInvoker with the runtime normalizer", () => { + test("wraps a shorthand invoker with the runtime normalizer", () => { const source = ` -const authInvoker = "kiosk"; -const result = await myWorkflow.trigger({ id: 1 }, { authInvoker }); +const invoker = "kiosk"; +const result = await myWorkflow.start({ id: 1 }, { invoker }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers( + const result = transformStartCalls( source, workflowNameMap, jobNameMap, @@ -877,18 +872,18 @@ const result = await myWorkflow.trigger({ id: 1 }, { authInvoker }); "my-auth", ); - expect(result).toContain("__tailor_normalizeTriggerOptions({ authInvoker })"); + expect(result).toContain("__tailor_normalizeStartOptions({ invoker })"); }); test("wraps a variable options argument with the runtime normalizer", () => { const source = ` -const opts = { authInvoker: "kiosk" }; -const result = await myWorkflow.trigger({ id: 1 }, opts); +const opts = { invoker: "kiosk" }; +const result = await myWorkflow.start({ id: 1 }, opts); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers( + const result = transformStartCalls( source, workflowNameMap, jobNameMap, @@ -898,18 +893,18 @@ const result = await myWorkflow.trigger({ id: 1 }, opts); ); expect(result).toContain( - 'tailor.workflow.triggerWorkflow("my-workflow", { id: 1 }, __tailor_normalizeTriggerOptions(opts))', + 'tailor.workflow.startWorkflow("my-workflow", { id: 1 }, __tailor_normalizeStartOptions(opts))', ); }); test("wraps spread options with the runtime normalizer", () => { const source = ` -const result = await myWorkflow.trigger({ id: 1 }, { ...base }); +const result = await myWorkflow.start({ id: 1 }, { ...base }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers( + const result = transformStartCalls( source, workflowNameMap, jobNameMap, @@ -918,17 +913,17 @@ const result = await myWorkflow.trigger({ id: 1 }, { ...base }); "my-auth", ); - expect(result).toContain("__tailor_normalizeTriggerOptions({ ...base })"); + expect(result).toContain("__tailor_normalizeStartOptions({ ...base })"); }); - test("transforms workflow.trigger() with an empty options object", () => { + test("transforms workflow.start() with an empty options object", () => { const source = ` -const result = await myWorkflow.trigger({ id: 1 }, {}); +const result = await myWorkflow.start({ id: 1 }, {}); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers( + const result = transformStartCalls( source, workflowNameMap, jobNameMap, @@ -938,19 +933,19 @@ const result = await myWorkflow.trigger({ id: 1 }, {}); ); expect(result).toContain( - 'tailor.workflow.triggerWorkflow("my-workflow", { id: 1 }, __tailor_normalizeTriggerOptions({}))', + 'tailor.workflow.startWorkflow("my-workflow", { id: 1 }, __tailor_normalizeStartOptions({}))', ); }); - test("injects the normalizer helper only once per file even for multiple trigger calls", () => { + test("injects the normalizer helper only once per file even for multiple start calls", () => { const source = ` -await myWorkflow.trigger({ id: 1 }, { authInvoker: "kiosk" }); -await myWorkflow.trigger({ id: 2 }, { authInvoker: "batch" }); +await myWorkflow.start({ id: 1 }, { invoker: "kiosk" }); +await myWorkflow.start({ id: 2 }, { invoker: "batch" }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers( + const result = transformStartCalls( source, workflowNameMap, jobNameMap, @@ -959,152 +954,149 @@ await myWorkflow.trigger({ id: 2 }, { authInvoker: "batch" }); "my-auth", ); - const matches = result.match(/const __tailor_normalizeTriggerOptions =/g); + const matches = result.match(/const __tailor_normalizeStartOptions =/g); expect(matches).toHaveLength(1); }); test("keeps options unchanged and omits the helper when authNamespace is not provided", () => { const source = ` -const result = await myWorkflow.trigger({ id: 1 }, { authInvoker: "kiosk" }); +const result = await myWorkflow.start({ id: 1 }, { invoker: "kiosk" }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); expect(result).toContain( - 'tailor.workflow.triggerWorkflow("my-workflow", { id: 1 }, { authInvoker: "kiosk" })', + 'tailor.workflow.startWorkflow("my-workflow", { id: 1 }, { invoker: "kiosk" })', ); - expect(result).not.toContain("__tailor_normalizeTriggerOptions"); + expect(result).not.toContain("__tailor_normalizeStartOptions"); }); }); - - describe("job trigger transformation", () => { - test("transforms job.trigger() calls to tailor.workflow.triggerJobFunction()", () => { + describe("job start transformation", () => { + test("transforms job.start() calls to tailor.workflow.startJobFunction()", () => { const source = ` -const result = await fetchCustomer.trigger({ customerId: "123" }); +const result = await fetchCustomer.start({ customerId: "123" }); `; const workflowNameMap = new Map(); const jobNameMap = new Map([["fetchCustomer", "fetch-customer"]]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); - expect(result).toContain('tailor.workflow.triggerJobFunction("fetch-customer"'); + expect(result).toContain('tailor.workflow.startJobFunction("fetch-customer"'); expect(result).toContain('{ customerId: "123" }'); }); - test("transforms job.trigger() without arguments", () => { + test("transforms job.start() without arguments", () => { const source = ` -const result = await simpleJob.trigger(); +const result = await simpleJob.start(); `; const workflowNameMap = new Map(); const jobNameMap = new Map([["simpleJob", "simple-job"]]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); - expect(result).toContain('tailor.workflow.triggerJobFunction("simple-job", undefined)'); + expect(result).toContain('tailor.workflow.startJobFunction("simple-job", undefined)'); }); - test("forwards a second options argument to triggerJobFunction", () => { + test("forwards a second options argument to startJobFunction", () => { const source = ` -const result = await fetchCustomer.trigger({ id: "123" }, { executionPolicyKey: "premium" }); +const result = await fetchCustomer.start({ id: "123" }, { executionPolicyKey: "premium" }); `; const workflowNameMap = new Map(); const jobNameMap = new Map([["fetchCustomer", "fetch-customer"]]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); expect(result).toContain( - 'tailor.workflow.triggerJobFunction("fetch-customer", { id: "123" }, { executionPolicyKey: "premium" })', + 'tailor.workflow.startJobFunction("fetch-customer", { id: "123" }, { executionPolicyKey: "premium" })', ); }); test("omits the options argument when the caller passes only args", () => { const source = ` -const result = await fetchCustomer.trigger({ id: "123" }); +const result = await fetchCustomer.start({ id: "123" }); `; const workflowNameMap = new Map(); const jobNameMap = new Map([["fetchCustomer", "fetch-customer"]]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); // Args only, no trailing options argument. - expect(result).toContain( - 'tailor.workflow.triggerJobFunction("fetch-customer", { id: "123" }))()', - ); + expect(result).toContain('tailor.workflow.startJobFunction("fetch-customer", { id: "123" })'); expect(result).not.toContain('{ id: "123" }, '); }); }); describe("false positive prevention", () => { - test("does not transform .trigger() calls on unknown identifiers", () => { + test("does not transform .start() calls on unknown identifiers", () => { const source = ` // This should NOT be transformed -const result = await someRandomObject.trigger({ data: "test" }); +const result = await someRandomObject.start({ data: "test" }); // Neither should this -const event = button.trigger("click"); +const event = button.start("click"); `; const workflowNameMap = new Map(); const jobNameMap = new Map(); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); // Should remain unchanged - expect(result).toContain('someRandomObject.trigger({ data: "test" })'); - expect(result).toContain('button.trigger("click")'); + expect(result).toContain('someRandomObject.start({ data: "test" })'); + expect(result).toContain('button.start("click")'); expect(result).not.toContain("tailor.workflow"); }); - test("only transforms trigger calls for known workflows and jobs", () => { + test("only transforms start calls for known workflows and jobs", () => { const source = ` // Known workflow - should be transformed -const wfResult = await orderWorkflow.trigger({ id: 1 }, { authInvoker: auth.invoker("admin") }); +const wfResult = await orderWorkflow.start({ id: 1 }, { invoker: "admin" }); // Known job - should be transformed -const jobResult = await fetchData.trigger({ id: 2 }); +const jobResult = await fetchData.start({ id: 2 }); // Unknown - should NOT be transformed -const unknown = await randomThing.trigger({ id: 3 }); +const unknown = await randomThing.start({ id: 3 }); `; const workflowNameMap = new Map([["orderWorkflow", "order-processing"]]); const jobNameMap = new Map([["fetchData", "fetch-data"]]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); // Known workflow transformed - expect(result).toContain('tailor.workflow.triggerWorkflow("order-processing"'); + expect(result).toContain('tailor.workflow.startWorkflow("order-processing"'); // Known job transformed - expect(result).toContain('tailor.workflow.triggerJobFunction("fetch-data"'); + expect(result).toContain('tailor.workflow.startJobFunction("fetch-data"'); // Unknown NOT transformed - expect(result).toContain("randomThing.trigger({ id: 3 })"); + expect(result).toContain("randomThing.start({ id: 3 })"); }); - test("transforms workflow.trigger() regardless of argument count", () => { + test("transforms workflow.start() regardless of argument count", () => { const source = ` -const result = await myWorkflow.trigger({ id: 1 }); +const result = await myWorkflow.start({ id: 1 }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); - expect(result).toContain('tailor.workflow.triggerWorkflow("my-workflow", { id: 1 })'); - expect(result).not.toContain("myWorkflow.trigger"); + expect(result).toContain('tailor.workflow.startWorkflow("my-workflow", { id: 1 })'); + expect(result).not.toContain("myWorkflow.start"); }); }); - describe("mixed workflow and job triggers", () => { - test("transforms both workflow and job triggers in the same source", () => { + describe("mixed workflow and job starts", () => { + test("transforms both workflow and job starts in the same source", () => { const source = ` async function processOrder(orderId: string) { - // Trigger a job to fetch data - const data = await fetchCustomer.trigger({ id: orderId }); + // Start a job to fetch data + const data = await fetchCustomer.start({ id: orderId }); - // Then trigger a workflow for processing - const workflowRunId = await orderWorkflow.trigger( + // Then start a workflow for processing + const workflowRunId = await orderWorkflow.start( { orderId, data }, - { authInvoker: auth.invoker("system") } + { invoker: "system" } ); return { data, workflowRunId }; @@ -1113,33 +1105,33 @@ async function processOrder(orderId: string) { const workflowNameMap = new Map([["orderWorkflow", "order-processing"]]); const jobNameMap = new Map([["fetchCustomer", "fetch-customer"]]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); - expect(result).toContain('tailor.workflow.triggerJobFunction("fetch-customer"'); - expect(result).toContain('tailor.workflow.triggerWorkflow("order-processing"'); + expect(result).toContain('tailor.workflow.startJobFunction("fetch-customer"'); + expect(result).toContain('tailor.workflow.startWorkflow("order-processing"'); }); }); - describe("async IIFE wrapping for job triggers", () => { - test("wraps job.trigger() in an async IIFE and preserves await", () => { + describe("direct job start transformation", () => { + test("replaces job.start() with startJobFunction() and preserves await", () => { const source = ` -const customer = await fetchCustomer.trigger({ customerId: "123" }); +const customer = await fetchCustomer.start({ customerId: "123" }); console.log(customer); `; const workflowNameMap = new Map(); const jobNameMap = new Map([["fetchCustomer", "fetch-customer"]]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); expect(result).toContain( - 'const customer = await (async () => tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" }))()', + 'const customer = await tailor.workflow.startJobFunction("fetch-customer", { customerId: "123" })', ); }); - test("wraps multiple job.trigger() calls in an async IIFE", () => { + test("replaces multiple job.start() calls directly", () => { const source = ` -const customer = await fetchCustomer.trigger({ customerId: "123" }); -const notification = await sendNotification.trigger({ message: "Hello" }); +const customer = await fetchCustomer.start({ customerId: "123" }); +const notification = await sendNotification.start({ message: "Hello" }); `; const workflowNameMap = new Map(); const jobNameMap = new Map([ @@ -1147,47 +1139,45 @@ const notification = await sendNotification.trigger({ message: "Hello" }); ["sendNotification", "send-notification"], ]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); - expect(result).toContain('(async () => tailor.workflow.triggerJobFunction("fetch-customer"'); - expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("send-notification"', - ); + expect(result).toContain('tailor.workflow.startJobFunction("fetch-customer"'); + expect(result).toContain('tailor.workflow.startJobFunction("send-notification"'); }); - test("does not wrap workflow.trigger() calls (already async)", () => { + test("does not wrap workflow.start() calls (already async)", () => { const source = ` -const executionId = await orderWorkflow.trigger({ orderId: "123" }, { authInvoker }); +const executionId = await orderWorkflow.start({ orderId: "123" }, { invoker }); `; const workflowNameMap = new Map([["orderWorkflow", "order-processing"]]); const jobNameMap = new Map(); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); - expect(result).toContain('await tailor.workflow.triggerWorkflow("order-processing"'); - expect(result).not.toContain("(async () => tailor.workflow.triggerWorkflow"); + expect(result).toContain('await tailor.workflow.startWorkflow("order-processing"'); + expect(result).not.toContain("(async () => tailor.workflow.startWorkflow"); }); - test("wraps job.trigger() without await so it still returns a Promise", () => { + test("replaces job.start() without await with the raw platform result", () => { const source = ` -const customerPromise = fetchCustomer.trigger({ customerId: "123" }); +const customerPromise = fetchCustomer.start({ customerId: "123" }); `; const workflowNameMap = new Map(); const jobNameMap = new Map([["fetchCustomer", "fetch-customer"]]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); expect(result).toContain( - 'const customerPromise = (async () => tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" }))()', + 'const customerPromise = tailor.workflow.startJobFunction("fetch-customer", { customerId: "123" })', ); expect(result).not.toMatch(/\bawait\b/); }); - test("wraps job.trigger() inside Promise.all array elements", () => { + test("replaces job.start() inside Promise.all array elements", () => { const source = ` const [customer, notification] = await Promise.all([ - fetchCustomer.trigger({ customerId: "123" }), - sendNotification.trigger({ message: "Hello" }), + fetchCustomer.start({ customerId: "123" }), + sendNotification.start({ message: "Hello" }), ]); `; const workflowNameMap = new Map(); @@ -1196,64 +1186,86 @@ const [customer, notification] = await Promise.all([ ["sendNotification", "send-notification"], ]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" }))()', + 'tailor.workflow.startJobFunction("fetch-customer", { customerId: "123" })', ); expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("send-notification", { message: "Hello" }))()', + 'tailor.workflow.startJobFunction("send-notification", { message: "Hello" })', ); expect(result).toContain("await Promise.all(["); }); - test("wraps job.trigger() before .then() chains", () => { + test("replaces job.start() before .then() chains without preserving Promise wrapping", () => { const source = ` -fetchCustomer.trigger({ customerId: "123" }).then((customer) => { +fetchCustomer.start({ customerId: "123" }).then((customer) => { console.log(customer); }); `; const workflowNameMap = new Map(); const jobNameMap = new Map([["fetchCustomer", "fetch-customer"]]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" }))().then(', + 'tailor.workflow.startJobFunction("fetch-customer", { customerId: "123" }).then(', ); }); - test("transforms only the outer call when a known trigger is nested inside another", () => { + test("transforms only the outer call when a known start call is nested inside another", () => { const source = ` -await myWorkflow.trigger(fetchCustomer.trigger({ customerId: "123" })); +await myWorkflow.start(fetchCustomer.start({ customerId: "123" })); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map([["fetchCustomer", "fetch-customer"]]); using warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); expect(result).toContain( - 'await tailor.workflow.triggerWorkflow("my-workflow", fetchCustomer.trigger({ customerId: "123" }));', + 'await tailor.workflow.startWorkflow("my-workflow", fetchCustomer.start({ customerId: "123" }));', ); expect(warnSpy).toHaveBeenCalledWith( - 'Nested trigger call "fetchCustomer.trigger(...)" inside "myWorkflow.trigger(...)" cannot be converted. Move it to a separate statement and pass the result instead.', + 'Nested start call "fetchCustomer.start(...)" inside "myWorkflow.start(...)" cannot be converted. Move it to a separate statement and pass the result instead.', ); }); - test("wraps job.trigger() nested inside an unknown .trigger() argument", () => { + test("replaces job.start() nested inside an unknown .start() argument", () => { const source = ` -unknown.trigger(fetchCustomer.trigger({ customerId: "123" })); +unknown.start(fetchCustomer.start({ customerId: "123" })); `; const workflowNameMap = new Map(); const jobNameMap = new Map([["fetchCustomer", "fetch-customer"]]); - const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); + const result = transformStartCalls(source, workflowNameMap, jobNameMap); expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" }))()', + 'tailor.workflow.startJobFunction("fetch-customer", { customerId: "123" })', ); - expect(result).toContain("unknown.trigger("); + expect(result).toContain("unknown.start("); }); }); }); + +describe("AST Transformer - hasStartCall", () => { + test("detects a plain .start( call", () => { + expect(hasStartCall("job.start({ id: 1 });")).toBe(true); + }); + + test("detects .start( split across whitespace/newlines", () => { + expect(hasStartCall("job.start\n (\n { id: 1 }\n );")).toBe(true); + }); + + test("detects .start( with a block comment before the parens", () => { + expect(hasStartCall("job.start/* why */({ id: 1 });")).toBe(true); + }); + + test("detects .start( with a line comment before the parens", () => { + expect(hasStartCall("job.start // why\n({ id: 1 });")).toBe(true); + }); + + test("returns false when there is no .start( call", () => { + expect(hasStartCall("job.stop({ id: 1 });")).toBe(false); + }); +}); diff --git a/packages/sdk/src/cli/services/workflow/ast-utils.ts b/packages/sdk/src/cli/services/workflow/ast-utils.ts index 13261bff8..591eb3ba0 100644 --- a/packages/sdk/src/cli/services/workflow/ast-utils.ts +++ b/packages/sdk/src/cli/services/workflow/ast-utils.ts @@ -21,7 +21,7 @@ export interface Replacement { text: string; } -export interface TriggerCallInfo { +export interface StartCallInfo { identifierName: string; callRange: { start: number; end: number }; argsText: string; @@ -100,15 +100,15 @@ function argumentSourceText(arg: unknown, sourceText: string): string | undefine } /** - * Get metadata for a static `identifier.trigger(...)` call. + * Get metadata for a static `identifier.start(...)` call. * @param node - AST node to inspect * @param sourceText - Source code text - * @returns Trigger call metadata, or null when the node is not a trigger call + * @returns Start call metadata, or null when the node is not a start call */ -export function getTriggerCallInfo( +export function getStartCallInfo( node: ASTNode | null | undefined, sourceText: string, -): TriggerCallInfo | null { +): StartCallInfo | null { if (!node || typeof node !== "object" || node.type !== "CallExpression") { return null; } @@ -124,7 +124,7 @@ export function getTriggerCallInfo( // callee may be a ComputedMemberExpression at runtime // oxlint-disable-next-line typescript/no-unnecessary-condition memberExpr.computed || - memberExpr.property.name !== "trigger" || + memberExpr.property.name !== "start" || memberExpr.object.type !== "Identifier" ) { return null; diff --git a/packages/sdk/src/cli/services/workflow/bundler.test.ts b/packages/sdk/src/cli/services/workflow/bundler.test.ts index 34325bdcf..9d75d3c01 100644 --- a/packages/sdk/src/cli/services/workflow/bundler.test.ts +++ b/packages/sdk/src/cli/services/workflow/bundler.test.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "pathe"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { buildTriggerContext, normalizeFilePath } from "#/cli/shared/trigger-context"; +import { buildStartContext, normalizeFilePath } from "#/cli/shared/start-context"; import { bundleWorkflowJobs } from "./bundler"; describe("bundleWorkflowJobs", () => { @@ -70,7 +70,7 @@ export const mainJob = createWorkflowJob({ } }); - describe("job trigger binding resolution", () => { + describe("job start binding resolution", () => { let tmpDir: string | undefined; afterEach(() => { @@ -97,7 +97,7 @@ import { createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; export const step = createWorkflowJob({ name: "step-a", body: async () => "a" }); export const mainA = createWorkflowJob({ name: "main-a", - body: async () => await step.trigger(), + body: async () => await step.start(), }); export default createWorkflow({ name: "workflow-a", mainJob: mainA }); `, @@ -110,12 +110,12 @@ import { createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; export const step = createWorkflowJob({ name: "step-b", body: async () => "b" }); export const mainB = createWorkflowJob({ name: "main-b", - body: async () => await step.trigger(), + body: async () => await step.start(), }); export default createWorkflow({ name: "workflow-b", mainJob: mainB }); `, ); - const context = await buildTriggerContext({ files: [firstFile, secondFile] }); + const context = await buildStartContext({ files: [firstFile, secondFile] }); const result = await bundleWorkflowJobs( [ @@ -132,8 +132,8 @@ export default createWorkflow({ name: "workflow-b", mainJob: mainB }); expect(result.mainJobDeps["main-a"]).toEqual(["main-a", "step-a"]); expect(result.usedJobNames).toEqual(["step-a", "main-a"]); - expect(result.bundledCode.get("main-a")).toMatch(/triggerJobFunction\([`'"]step-a/); - expect(result.bundledCode.get("main-a")).not.toMatch(/triggerJobFunction\([`'"]step-b/); + expect(result.bundledCode.get("main-a")).toMatch(/startJobFunction\([`'"]step-a/); + expect(result.bundledCode.get("main-a")).not.toMatch(/startJobFunction\([`'"]step-b/); }); test("includes jobs referenced through aliased named imports", async () => { @@ -155,12 +155,12 @@ import { step as importedStep } from "./jobs"; export const mainJob = createWorkflowJob({ name: "main-job", - body: async () => await importedStep.trigger(), + body: async () => await importedStep.start(), }); export default createWorkflow({ name: "workflow", mainJob }); `, ); - const context = await buildTriggerContext({ files: [jobsFile, callerFile] }); + const context = await buildStartContext({ files: [jobsFile, callerFile] }); const result = await bundleWorkflowJobs( [ @@ -175,7 +175,7 @@ export default createWorkflow({ name: "workflow", mainJob }); expect(result.mainJobDeps["main-job"]).toEqual(["main-job", "step-a"]); expect(result.usedJobNames).toEqual(["step-a", "main-job"]); - expect(result.bundledCode.get("main-job")).toMatch(/triggerJobFunction\([`'"]step-a/); + expect(result.bundledCode.get("main-job")).toMatch(/startJobFunction\([`'"]step-a/); }); test("does not include a job whose binding is shadowed by a parameter", async () => { @@ -189,12 +189,12 @@ import { createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; export const step = createWorkflowJob({ name: "step-a", body: async () => "a" }); export const mainJob = createWorkflowJob({ name: "main-job", - body: async (step: { trigger(): Promise }) => await step.trigger(), + body: async (step: { start(): Promise }) => await step.start(), }); export default createWorkflow({ name: "workflow", mainJob }); `, ); - const context = await buildTriggerContext({ files: [workflowFile] }); + const context = await buildStartContext({ files: [workflowFile] }); const result = await bundleWorkflowJobs( [ @@ -209,7 +209,7 @@ export default createWorkflow({ name: "workflow", mainJob }); expect(result.mainJobDeps["main-job"]).toEqual(["main-job"]); expect(result.usedJobNames).toEqual(["main-job"]); - expect(result.bundledCode.get("main-job")).not.toContain("triggerJobFunction"); + expect(result.bundledCode.get("main-job")).not.toContain("startJobFunction"); }); }); @@ -219,7 +219,7 @@ export default createWorkflow({ name: "workflow", mainJob }); type BuildBundleFixtureOptions = { ext: string; importPath: string; - triggerArgs?: string; + startArgs?: string; }; afterEach(() => { @@ -230,7 +230,7 @@ export default createWorkflow({ name: "workflow", mainJob }); }); const buildBundleFixture = (options: BuildBundleFixtureOptions) => { - const { ext, importPath, triggerArgs = `{ input: 0 }, { authInvoker: "admin" }` } = options; + const { ext, importPath, startArgs = `{ input: 0 }, { invoker: "admin" }` } = options; // Use realpathSync to avoid macOS symlink mismatch (/var -> /private/var) tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "bundler-test-"))); @@ -265,7 +265,7 @@ import simpleWorkflow from "${importPath}"; export const callerJob = createWorkflowJob({ name: "caller-job", body: async () => { - const executionId = await simpleWorkflow.trigger(${triggerArgs}); + const executionId = await simpleWorkflow.start(${startArgs}); return { executionId }; }, }); @@ -283,7 +283,7 @@ export default createWorkflow({ ]; const mainJobNames = ["caller-job"]; - const triggerContext = { + const startContext = { modules: new Map([ [ normalizeFilePath(simpleFile), @@ -309,23 +309,23 @@ export default createWorkflow({ authNamespace: "default", }; - return bundleWorkflowJobs(allJobs, mainJobNames, {}, triggerContext, tmpDir); + return bundleWorkflowJobs(allJobs, mainJobNames, {}, startContext, tmpDir); }; test.each([ { label: "cross-file default import", ext: "ts", importPath: "./simple" }, { label: ".mts dependency files", ext: "mts", importPath: "./simple.mjs" }, - ])("transforms workflow.trigger() from $label", async (options) => { + ])("transforms workflow.start() from $label", async (options) => { const { ext, importPath } = options; const result = await buildBundleFixture({ ext, importPath }); expect(result.bundledCode.has("caller-job")).toBe(true); const callerCode = result.bundledCode.get("caller-job")!; - // The trigger call should be transformed to triggerWorkflow - expect(callerCode).toContain("triggerWorkflow"); - // The raw simpleWorkflow.trigger() should NOT remain in the bundle - expect(callerCode).not.toContain("simpleWorkflow.trigger"); + // The start call should be transformed to startWorkflow + expect(callerCode).toContain("startWorkflow"); + // The raw simpleWorkflow.start() should NOT remain in the bundle + expect(callerCode).not.toContain("simpleWorkflow.start"); }); test("strips platform-bundle-only symbols from cross-file default import", async () => { @@ -335,25 +335,26 @@ export default createWorkflow({ // tree-shake every test-only symbol; otherwise an unsubstituted process.env.* // reaches the Platform Web runtime (no `process`) and crashes. for (const code of result.bundledCode.values()) { - expect(code).not.toContain("process.env.TAILOR_PLATFORM_BUNDLE"); + expect(code).not.toContain("process.env.__TAILOR_PLATFORM_BUNDLE"); + expect(code).not.toContain("async_hooks"); expect(code).not.toContain("job-registry"); expect(code).not.toContain("registerJob"); expect(code).not.toContain("platformSerialize"); } }); - test("transforms workflow.trigger() without an options argument", async () => { + test("transforms workflow.start() without an options argument", async () => { const result = await buildBundleFixture({ ext: "ts", importPath: "./simple", - triggerArgs: "{ input: 0 }", + startArgs: "{ input: 0 }", }); expect(result.bundledCode.has("caller-job")).toBe(true); const callerCode = result.bundledCode.get("caller-job")!; - expect(callerCode).toContain("triggerWorkflow"); - expect(callerCode).not.toContain("simpleWorkflow.trigger"); + expect(callerCode).toContain("startWorkflow"); + expect(callerCode).not.toContain("simpleWorkflow.start"); }); }); }); diff --git a/packages/sdk/src/cli/services/workflow/bundler.ts b/packages/sdk/src/cli/services/workflow/bundler.ts index 8c5424735..22e3bc384 100644 --- a/packages/sdk/src/cli/services/workflow/bundler.ts +++ b/packages/sdk/src/cli/services/workflow/bundler.ts @@ -10,12 +10,12 @@ import { logger, styles } from "#/cli/shared/logger"; import { platformBundleDefinePlugin } from "#/cli/shared/platform-bundle-plugin"; import { resolveTSConfigWithFallback } from "#/cli/shared/resolve-tsconfig"; import { INVOKER_EXPR } from "#/cli/shared/runtime-exprs"; -import { serializeTriggerContext, type TriggerContext } from "#/cli/shared/trigger-context"; +import { serializeStartContext, type StartContext } from "#/cli/shared/start-context"; import { createVirtualEntry } from "#/cli/shared/virtual-entry"; import ml from "#/utils/multiline"; import { findAllJobs } from "./job-detector"; import { transformWorkflowSource } from "./source-transformer"; -import { detectResolvedTriggerCalls, transformFunctionTriggers } from "./trigger-transformer"; +import { detectResolvedStartCalls, hasStartCall, transformStartCalls } from "./start-transformer"; import type { LogLevel } from "#/configure/config/types"; function safeRealpath(p: string): string { @@ -48,14 +48,14 @@ export interface BundleWorkflowJobsResult { * * This function: * 1. Detects which jobs are actually used (mainJobs + their dependencies) - * 2. Uses a transform plugin to transform trigger calls during bundling + * 2. Uses a transform plugin to transform start calls during bundling * 3. Creates an in-memory entry module and bundles with tree-shaking * * Returns metadata about which jobs each workflow uses. * @param allJobs - All available job infos * @param mainJobNames - Names of main jobs * @param env - Environment variables to inject - * @param triggerContext - Trigger context for transformations + * @param startContext - Start context for transformations * @param baseDir - Directory the owning config's tsconfig is resolved against * @param cache - Optional bundle cache for skipping unchanged builds * @param inlineSourcemap - Whether to enable inline sourcemaps @@ -66,7 +66,7 @@ export async function bundleWorkflowJobs( allJobs: JobInfo[], mainJobNames: string[], env: Record = {}, - triggerContext: TriggerContext, + startContext: StartContext, baseDir: string, cache?: BundleCache, inlineSourcemap?: boolean, @@ -78,7 +78,7 @@ export async function bundleWorkflowJobs( } // Filter to only used jobs and get per-mainJob dependencies - const { usedJobs, mainJobDeps } = await filterUsedJobs(allJobs, mainJobNames, triggerContext); + const { usedJobs, mainJobDeps } = await filterUsedJobs(allJobs, mainJobNames, startContext); logger.newline(); logger.log( @@ -95,7 +95,7 @@ export async function bundleWorkflowJobs( usedJobs, tsconfig, env, - triggerContext, + startContext, cache, inlineSourcemap, bundleLogLevel, @@ -125,18 +125,18 @@ interface FilterUsedJobsResult { * Filter jobs to only include those that are actually used. * A job is "used" if: * - It's a mainJob of a workflow - * - It's called via .trigger() from another used job (transitively) + * - It's called via .start() from another used job (transitively) * * Also returns a map of mainJob -> all jobs it depends on (for metadata). * @param allJobs - All available job infos * @param mainJobNames - Names of main jobs - * @param triggerContext - Module binding metadata for resolving trigger targets + * @param startContext - Module binding metadata for resolving start targets * @returns Used jobs and main job dependency map */ async function filterUsedJobs( allJobs: JobInfo[], mainJobNames: string[], - triggerContext: TriggerContext, + startContext: StartContext, ): Promise { if (allJobs.length === 0 || mainJobNames.length === 0) { return { usedJobs: [], mainJobDeps: {} }; @@ -150,8 +150,8 @@ async function filterUsedJobs( jobsBySourceFile.set(job.sourceFile, existing); } - // Detect trigger calls and build dependency graph - // Maps job name -> set of job names it triggers + // Detect start calls and build dependency graph + // Maps job name -> set of job names it starts const dependencies = new Map>(); // Process all source files in parallel @@ -163,14 +163,9 @@ async function filterUsedJobs( // Find all jobs in this file to get body ranges const detectedJobs = findAllJobs(program, source); - const triggerCalls = detectResolvedTriggerCalls( - program, - source, - triggerContext, - sourceFile, - ); - - // For each job in this file, find which triggers are inside its body + const startCalls = detectResolvedStartCalls(program, source, startContext, sourceFile); + + // For each job in this file, find which start calls are inside its body const jobDependencies: Array<{ jobName: string; deps: Set }> = []; for (const job of jobs) { @@ -179,8 +174,8 @@ async function filterUsedJobs( const jobDeps = new Set(); - for (const call of triggerCalls) { - // Check if this trigger call is inside the job's body + for (const call of startCalls) { + // Check if this start call is inside the job's body if ( call.kind === "job" && call.callRange.start >= detectedJob.bodyValueRange.start && @@ -249,12 +244,12 @@ async function bundleSingleJob( allJobs: JobInfo[], tsconfig: string | undefined, env: Record, - triggerContext: TriggerContext, + startContext: StartContext, cache?: BundleCache, inlineSourcemap?: boolean, bundleLogLevel: LogLevel = "DEBUG", ): Promise<[string, string]> { - const serializedTriggerContext = serializeTriggerContext(triggerContext); + const serializedStartContext = serializeStartContext(startContext); // Include sorted env variables as a prefix so that env changes invalidate the cache const sortedEnvPrefix = JSON.stringify( @@ -262,7 +257,7 @@ async function bundleSingleJob( ); const contextHash = computeBundlerContextHash({ sourceFile: job.sourceFile, - serializedTriggerContext, + extraContext: serializedStartContext, tsconfig, inlineSourcemap, bundleLogLevel, @@ -292,7 +287,7 @@ async function bundleSingleJob( // Pre-compute once to avoid redundant realpathSync calls per module const resolvedSourceFile = safeRealpath(job.sourceFile); - // Step 2: Bundle with a transform plugin that transforms trigger calls + // Step 2: Bundle with a transform plugin that transforms start calls // Collect export names for enhanced AST removal (catches jobs missed by AST detection) const otherJobExportNames = allJobs .filter( @@ -302,7 +297,7 @@ async function bundleSingleJob( ) .map((j) => j.exportName); - // Create transform plugin to transform trigger calls and remove other job declarations + // Create transform plugin to transform start calls and remove other job declarations const transformPlugin: rolldown.Plugin = { name: "workflow-transform", transform: { @@ -312,11 +307,11 @@ async function bundleSingleJob( }, }, handler(code, id) { - // Only transform source files that contain workflow jobs or trigger calls + // Only transform source files that contain workflow jobs or start calls if ( !code.includes("createWorkflowJob") && !code.includes("createWorkflow") && - !code.includes(".trigger(") + !hasStartCall(code) ) { return null; } @@ -336,9 +331,9 @@ async function bundleSingleJob( ); } - // Apply workflow.trigger / job.trigger transformation. - if (transformed.includes(".trigger(")) { - transformed = transformFunctionTriggers(transformed, triggerContext, id); + // Apply workflow.start / job.start transformation. + if (hasStartCall(transformed)) { + transformed = transformStartCalls(transformed, startContext, id); } if (transformed === code) return null; diff --git a/packages/sdk/src/cli/services/workflow/service.test.ts b/packages/sdk/src/cli/services/workflow/service.test.ts new file mode 100644 index 000000000..4437b6b87 --- /dev/null +++ b/packages/sdk/src/cli/services/workflow/service.test.ts @@ -0,0 +1,37 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { describe, expect, test } from "vitest"; +import { tempCwd } from "#/cli/shared/test-helpers/temp-cwd"; +import { createWorkflowService } from "./service"; + +describe("createWorkflowService", () => { + test("does not strip runtime start from an unbranded default export", async () => { + using tmp = tempCwd("sdk-workflow-service-"); + const workflowFile = path.join(tmp.dir, "workflow.mjs"); + fs.writeFileSync( + workflowFile, + ` +export const mainJob = { + name: "main-job", + start: () => {}, + body: () => {}, +}; + +export default { + name: "looks-like-workflow", + mainJob, + start: () => {}, +}; +`, + ); + + const service = createWorkflowService({ + config: { files: ["workflow.mjs"] }, + baseDir: tmp.dir, + }); + + await service.loadWorkflows(); + + expect(service.workflows).toEqual({}); + }); +}); diff --git a/packages/sdk/src/cli/services/workflow/service.ts b/packages/sdk/src/cli/services/workflow/service.ts index d71d98aa4..3c4285703 100644 --- a/packages/sdk/src/cli/services/workflow/service.ts +++ b/packages/sdk/src/cli/services/workflow/service.ts @@ -13,6 +13,19 @@ export interface CollectedJob { sourceFile: string; } +function stripRuntimeStart(workflow: unknown): unknown { + if ( + !isSdkBranded(workflow, "workflow") || + workflow === null || + typeof workflow !== "object" || + !("start" in workflow) + ) { + return workflow; + } + const { start: _start, ...rest } = workflow as Record; + return rest; +} + interface WorkflowLoadResult { workflows: Record; workflowSources: Array<{ workflow: Workflow; sourceFile: string }>; @@ -183,7 +196,7 @@ async function loadFileContent(filePath: string): Promise<{ for (const [exportName, exportValue] of Object.entries(module)) { // Check if it's a workflow (default export) if (exportName === "default") { - const workflowResult = WorkflowSchema.safeParse(exportValue); + const workflowResult = WorkflowSchema.safeParse(stripRuntimeStart(exportValue)); if (workflowResult.success) { workflow = workflowResult.data; } else if (isSdkBranded(exportValue, ["workflow", "workflow-job"])) { diff --git a/packages/sdk/src/cli/services/workflow/trigger-transformer.ts b/packages/sdk/src/cli/services/workflow/start-transformer.ts similarity index 72% rename from packages/sdk/src/cli/services/workflow/trigger-transformer.ts rename to packages/sdk/src/cli/services/workflow/start-transformer.ts index 4708dd5ab..2fa42cd27 100644 --- a/packages/sdk/src/cli/services/workflow/trigger-transformer.ts +++ b/packages/sdk/src/cli/services/workflow/start-transformer.ts @@ -3,30 +3,54 @@ import * as path from "pathe"; import { logger } from "#/cli/shared/logger"; import { normalizeFilePath, - type TriggerContext, - type TriggerModuleBindings, - type TriggerTarget, -} from "#/cli/shared/trigger-context"; + type StartContext, + type StartModuleBindings, + type StartTarget, +} from "#/cli/shared/start-context"; import { type ASTNode, type Replacement, - type TriggerCallInfo, + type StartCallInfo, applyReplacements, getModuleExportName, - getTriggerCallInfo, + getStartCallInfo, } from "./ast-utils"; import type { Program } from "@oxc-project/types"; import type { Plugin } from "rolldown"; -export interface ResolvedTriggerCall extends TriggerCallInfo { +export interface ResolvedStartCall extends StartCallInfo { kind: "job" | "workflow"; targetName: string; } -const NORMALIZER_IDENTIFIER = "__tailor_normalizeTriggerOptions"; +const START_CALL_RE = /\.start(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*\(/; + +/** + * Fast pre-check for whether `code` contains a `.start(` call, tolerating + * whitespace, newlines, and comments between `.start` and `(` that a plain + * substring check would miss. + * @param code - Source text to scan + * @returns Whether `code` contains a `.start(` call + */ +export function hasStartCall(code: string): boolean { + return START_CALL_RE.test(code); +} +const NORMALIZER_IDENTIFIER = "__tailor_normalizeStartOptions"; + +/** + * Build the source text of the injected normalizer helper. + * + * Renames an `invoker` in the start options to the `authInvoker` form the + * platform RPC expects: a plain string (machine user name) becomes + * `{ namespace, machineUserName }`, while an object form passes through + * as-is. Any other options value is unchanged. The auth namespace is baked + * in at bundle time. + * @param authNamespace - Auth service namespace to embed + * @returns Source line defining the helper + */ function buildNormalizerHelperSource(authNamespace: string): string { - return `const ${NORMALIZER_IDENTIFIER} = (o) => o && typeof o.authInvoker === "string" ? { ...o, authInvoker: { namespace: ${JSON.stringify(authNamespace)}, machineUserName: o.authInvoker } } : o;\n`; + return `const ${NORMALIZER_IDENTIFIER} = (o) => { if (!o) return o; const { invoker, ...rest } = o; return typeof invoker === "string" ? { ...rest, authInvoker: { namespace: ${JSON.stringify(authNamespace)}, machineUserName: invoker } } : typeof invoker === "object" ? { ...rest, authInvoker: invoker } : o; };\n`; } function collectBindingNames(node: ASTNode | null | undefined, names: Set): void { @@ -215,10 +239,10 @@ function walkBindingAware( } function resolveRelativeImport( - context: TriggerContext, + context: StartContext, currentFilePath: string, importSource: string, -): TriggerModuleBindings | undefined { +): StartModuleBindings | undefined { if (!importSource.startsWith(".")) return undefined; const currentDirectory = path.dirname(currentFilePath.replace(/[?#].*$/, "")); const modulePath = normalizeFilePath(path.resolve(currentDirectory, importSource)); @@ -227,10 +251,10 @@ function resolveRelativeImport( function collectLocalTargets( program: Program, - context: TriggerContext, + context: StartContext, currentFilePath: string, -): Map { - const targets = new Map(); +): Map { + const targets = new Map(); const currentModule = context.modules.get(normalizeFilePath(currentFilePath)); if (currentModule) { for (const [localName, target] of currentModule.localBindings) { @@ -263,71 +287,73 @@ function collectLocalTargets( return targets; } -function detectTriggerCallsWithTargets( +function detectStartCallsWithTargets( program: Program, sourceText: string, - targets: Map, -): ResolvedTriggerCall[] { - const calls: ResolvedTriggerCall[] = []; + targets: Map, +): ResolvedStartCall[] { + const calls: ResolvedStartCall[] = []; const targetNames = new Set(targets.keys()); walkBindingAware(program, targetNames, (node, shadowedNames) => { - const triggerCall = getTriggerCallInfo(node, sourceText); - if (!triggerCall || shadowedNames.has(triggerCall.identifierName)) return; - const target = targets.get(triggerCall.identifierName); + const startCall = getStartCallInfo(node, sourceText); + if (!startCall || shadowedNames.has(startCall.identifierName)) return; + const target = targets.get(startCall.identifierName); if (target) { - calls.push({ ...triggerCall, kind: target.kind, targetName: target.name }); + calls.push({ ...startCall, kind: target.kind, targetName: target.name }); } }); return calls; } -export function detectResolvedTriggerCalls( +export function detectResolvedStartCalls( program: Program, sourceText: string, - context: TriggerContext, + context: StartContext, currentFilePath: string, -): ResolvedTriggerCall[] { - return detectTriggerCallsWithTargets( +): ResolvedStartCall[] { + return detectStartCallsWithTargets( program, sourceText, collectLocalTargets(program, context, currentFilePath), ); } -export function transformFunctionTriggers( +export function transformStartCalls( source: string, - triggerContext: TriggerContext, + startContext: StartContext, currentFilePath: string, ): string { const { program } = parseSync("input.ts", source); - const localTargets = collectLocalTargets(program, triggerContext, currentFilePath); - const { authNamespace } = triggerContext; - const allTriggerCalls = detectTriggerCallsWithTargets(program, source, localTargets); - const nestedTriggerCalls: Array<{ call: ResolvedTriggerCall; parent: ResolvedTriggerCall }> = []; - const triggerCalls = allTriggerCalls.filter((call) => { - const parent = allTriggerCalls.find( + const localTargets = collectLocalTargets(program, startContext, currentFilePath); + const { authNamespace } = startContext; + const allStartCalls = detectStartCallsWithTargets(program, source, localTargets); + const nestedStartCalls: Array<{ call: ResolvedStartCall; parent: ResolvedStartCall }> = []; + const startCalls = allStartCalls.filter((call) => { + const parent = allStartCalls.find( (other) => other !== call && other.callRange.start <= call.callRange.start && call.callRange.end <= other.callRange.end, ); if (!parent) return true; - nestedTriggerCalls.push({ call, parent }); + nestedStartCalls.push({ call, parent }); return false; }); - for (const { call, parent } of nestedTriggerCalls) { + for (const { call, parent } of nestedStartCalls) { logger.warn( - `Nested trigger call "${call.identifierName}.trigger(...)" inside "${parent.identifierName}.trigger(...)" cannot be converted. Move it to a separate statement and pass the result instead.`, + `Nested start call "${call.identifierName}.start(...)" inside "${parent.identifierName}.start(...)" cannot be converted. Move it to a separate statement and pass the result instead.`, ); } const replacements: Replacement[] = []; + // Whether any workflow start invoker was wrapped with the runtime + // normalizer. Used to decide whether to inject the helper at the top. let needsNormalizerHelper = false; - for (const call of triggerCalls) { + for (const call of startCalls) { let transformedCall: string; if (call.kind === "workflow") { let optionsPart = ""; @@ -339,10 +365,10 @@ export function transformFunctionTriggers( optionsPart = `, ${call.optionsText}`; } } - transformedCall = `tailor.workflow.triggerWorkflow(${JSON.stringify(call.targetName)}, ${call.argsText || "undefined"}${optionsPart})`; + transformedCall = `tailor.workflow.startWorkflow(${JSON.stringify(call.targetName)}, ${call.argsText || "undefined"}${optionsPart})`; } else { const optionsPart = call.optionsText !== undefined ? `, ${call.optionsText}` : ""; - transformedCall = `(async () => tailor.workflow.triggerJobFunction(${JSON.stringify(call.targetName)}, ${call.argsText || "undefined"}${optionsPart}))()`; + transformedCall = `tailor.workflow.startJobFunction(${JSON.stringify(call.targetName)}, ${call.argsText || "undefined"}${optionsPart})`; } replacements.push({ start: call.callRange.start, @@ -357,18 +383,18 @@ export function transformFunctionTriggers( : transformed; } -export function createTriggerTransformPlugin( - triggerContext: TriggerContext | undefined, +export function createStartTransformPlugin( + startContext: StartContext | undefined, ): Plugin | undefined { - if (!triggerContext) return undefined; + if (!startContext) return undefined; return { - name: "trigger-transform", + name: "start-transform", transform: { filter: { id: { include: [/\.(ts|mts|cts|js|mjs|cjs)$/] } }, handler(code, id) { - if (!code.includes(".trigger(")) return null; - return { code: transformFunctionTriggers(code, triggerContext, id) }; + if (!hasStartCall(code)) return null; + return { code: transformStartCalls(code, startContext, id) }; }, }, }; diff --git a/packages/sdk/src/cli/shared/args.ts b/packages/sdk/src/cli/shared/args.ts index d746e1b97..f3b5d809c 100644 --- a/packages/sdk/src/cli/shared/args.ts +++ b/packages/sdk/src/cli/shared/args.ts @@ -238,8 +238,8 @@ export const workspaceArgs = { export const configArg = { config: arg(z.string().default("tailor.config.ts"), { alias: "c", - description: "Path to SDK config file", - env: "TAILOR_PLATFORM_SDK_CONFIG_PATH", + description: "Path to Tailor config file", + env: "TAILOR_CONFIG_PATH", completion: { type: "file", extensions: ["ts"] }, }), } satisfies ArgsShape; diff --git a/packages/sdk/src/cli/shared/auth-namespace.test.ts b/packages/sdk/src/cli/shared/auth-namespace.test.ts new file mode 100644 index 000000000..b7f7edc07 --- /dev/null +++ b/packages/sdk/src/cli/shared/auth-namespace.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; +import { getApplicationAuthNamespace } from "./auth-namespace"; + +describe("getApplicationAuthNamespace", () => { + test("uses the local auth service name first", () => { + expect( + getApplicationAuthNamespace({ + authService: { config: { name: "local-auth" } }, + config: { auth: { name: "external-auth", external: true } }, + }), + ).toBe("local-auth"); + }); + + test("uses external auth config name when no local auth service exists", () => { + expect( + getApplicationAuthNamespace({ + config: { auth: { name: "external-auth", external: true } }, + }), + ).toBe("external-auth"); + }); + + test("returns undefined when no auth is configured", () => { + expect(getApplicationAuthNamespace({ config: {} })).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/cli/shared/auth-namespace.ts b/packages/sdk/src/cli/shared/auth-namespace.ts new file mode 100644 index 000000000..fb6032417 --- /dev/null +++ b/packages/sdk/src/cli/shared/auth-namespace.ts @@ -0,0 +1,17 @@ +import type { AppConfig } from "#/configure/config/types"; + +type AuthNamespaceApplication = { + authService?: { config: { name: string } }; + config?: Pick; +}; + +/** + * Resolve the auth namespace configured for an application. + * @param application - Loaded application with local or external Auth config + * @returns Auth namespace, or undefined when no Auth config is present + */ +export function getApplicationAuthNamespace( + application: AuthNamespaceApplication, +): string | undefined { + return application.authService?.config.name ?? application.config?.auth?.name; +} diff --git a/packages/sdk/src/cli/shared/beta.ts b/packages/sdk/src/cli/shared/beta.ts index 3702bfddc..c0286d9a8 100644 --- a/packages/sdk/src/cli/shared/beta.ts +++ b/packages/sdk/src/cli/shared/beta.ts @@ -2,7 +2,7 @@ import { logger } from "./logger"; /** * Warn that a feature is in beta. - * @param {string} featureName - Name of the beta feature (e.g., "tailordb erd", "tailordb migration") + * @param {string} featureName - Name of the beta feature (e.g., "tailordb migration") */ export function logBetaWarning(featureName: string): void { logger.warn( diff --git a/packages/sdk/src/cli/shared/builtin-commands.ts b/packages/sdk/src/cli/shared/builtin-commands.ts new file mode 100644 index 000000000..9b4c219ee --- /dev/null +++ b/packages/sdk/src/cli/shared/builtin-commands.ts @@ -0,0 +1,39 @@ +/** + * Top-level builtin command names. A plugin named the same as one of these is + * shadowed by the builtin and can never be dispatched. + * + * This list is the single source of truth used by `plugin list` to flag + * shadowed plugins without importing the (cyclic) command tree. It is kept in + * sync with `main-command.ts` by a drift test in `options.test.ts`. + */ +export const BUILTIN_COMMAND_NAMES = [ + "api", + "auth", + "authconnection", + "crashreport", + "deploy", + "executor", + "function", + "generate", + "init", + "login", + "logout", + "machineuser", + "oauth2client", + "open", + "organization", + "plugin", + "profile", + "query", + "remove", + "secret", + "setup", + "show", + "skills", + "staticwebsite", + "tailordb", + "upgrade", + "user", + "workflow", + "workspace", +] as const; diff --git a/packages/sdk/src/cli/shared/client.test.ts b/packages/sdk/src/cli/shared/client.test.ts index 56326a9bf..36817ec62 100644 --- a/packages/sdk/src/cli/shared/client.test.ts +++ b/packages/sdk/src/cli/shared/client.test.ts @@ -36,6 +36,20 @@ vi.mock("#/cli/crashreport/index", () => ({ reportCrash: vi.fn(), })); +describe("client environment configuration", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + test("uses TAILOR_PLATFORM_URL for the platform base URL", async () => { + vi.resetModules(); + vi.stubEnv("TAILOR_PLATFORM_URL", "https://api.staging.tailor.test"); + const client = await import("./client"); + expect(client.getPlatformBaseUrl()).toBe("https://api.staging.tailor.test"); + }); +}); + describe("createTransport", () => { afterEach(() => { vi.clearAllMocks(); diff --git a/packages/sdk/src/cli/shared/client.ts b/packages/sdk/src/cli/shared/client.ts index d0e827a83..ed7b164ad 100644 --- a/packages/sdk/src/cli/shared/client.ts +++ b/packages/sdk/src/cli/shared/client.ts @@ -640,7 +640,9 @@ export async function fetchUserInfo(accessToken: string, config?: PlatformClient } const rawJson = await resp.json(); + // strip unknown keys const schema = z.object({ + sub: z.string(), email: z.string(), }); return schema.parse(rawJson); @@ -759,6 +761,7 @@ export async function fetchMachineUserToken(url: string, clientId: string, clien } const rawJson = await resp.json(); + // strip unknown keys const schema = z.object({ token_type: z.string(), access_token: z.string(), diff --git a/packages/sdk/src/cli/shared/config-loader.ts b/packages/sdk/src/cli/shared/config-loader.ts index bf70dd568..3f85e9c9e 100644 --- a/packages/sdk/src/cli/shared/config-loader.ts +++ b/packages/sdk/src/cli/shared/config-loader.ts @@ -2,14 +2,11 @@ import * as fs from "node:fs"; import { pathToFileURL } from "node:url"; import * as path from "pathe"; import { AppConfigSchema } from "#/parser/app-config/schema"; -import { CodeGeneratorSchema, BaseGeneratorConfigSchema } from "#/parser/generator-config/schema"; import { PluginConfigSchema } from "#/parser/plugin-config/index"; -import { builtinPlugins } from "#/plugin/builtin/registry"; import { loadConfigPath } from "./context"; import { installCliTailordbStub } from "./mock"; import type { AppConfig } from "#/configure/config/types"; import type { Plugin } from "#/plugin/types"; -import type { z } from "zod"; /** * Loaded configuration with resolved path @@ -17,25 +14,20 @@ import type { z } from "zod"; export type LoadedConfig = AppConfig & { path: string }; export interface LoadConfigOptions { - /** Import cache-busting value for watch-mode reloads. */ + /** Import cache-busting value for callers that reload the config module after a rebuild. */ importNonce?: string; } -// Generator schema for custom CodeGenerator objects (builtin generators are handled as plugins) -const GeneratorConfigSchema = CodeGeneratorSchema.brand("CodeGenerator"); - -export type Generator = z.output; - /** - * Load Tailor configuration file and associated generators and plugins. + * Load Tailor configuration file and associated plugins. * @param configPath - Optional explicit config path * @param options - Optional module import behavior. - * @returns Loaded config, generators, plugins, and config path + * @returns Loaded config, plugins, and config path */ export async function loadConfig( configPath?: string, options: LoadConfigOptions = {}, -): Promise<{ config: LoadedConfig; generators: Generator[]; plugins: Plugin[] }> { +): Promise<{ config: LoadedConfig; plugins: Plugin[] }> { installCliTailordbStub(); const foundPath = loadConfigPath(configPath); if (!foundPath) { @@ -66,50 +58,11 @@ export async function loadConfig( throw new Error(`Invalid Tailor config in ${resolvedPath}:\n${issues}`); } - // Collect all generator exports (generators, generators2, etc.) - const allGenerators: Generator[] = []; // Collect all plugin exports (plugins, plugins2, etc.) const allPlugins: Plugin[] = []; for (const value of Object.values(configModule)) { if (Array.isArray(value)) { - // Try to parse as generators (converting builtin tuples to plugins) - const generatorParsed = value.reduce( - (acc, item) => { - if (!acc.success) return acc; - - // Check if this is a builtin generator tuple that should be converted to a plugin - const baseResult = BaseGeneratorConfigSchema.safeParse(item); - if (baseResult.success && Array.isArray(baseResult.data)) { - const [id, options] = baseResult.data as [string, Record]; - const pluginFactory = builtinPlugins.get(id); - if (pluginFactory) { - acc.convertedPlugins.push(pluginFactory(options)); - return acc; - } - } - - // Try to parse as a custom CodeGenerator object - const result = GeneratorConfigSchema.safeParse(item); - if (result.success) { - acc.items.push(result.data); - } else { - acc.success = false; - } - return acc; - }, - { success: true, items: [] as Generator[], convertedPlugins: [] as Plugin[] }, - ); - if ( - generatorParsed.success && - (generatorParsed.items.length > 0 || generatorParsed.convertedPlugins.length > 0) - ) { - allGenerators.push(...generatorParsed.items); - allPlugins.push(...generatorParsed.convertedPlugins); - continue; - } - - // Try to parse as plugins const pluginParsed = value.reduce( (acc, item) => { if (!acc.success) return acc; @@ -132,7 +85,6 @@ export async function loadConfig( return { config: { ...configModule.default, path: resolvedPath } as LoadedConfig, - generators: allGenerators, plugins: allPlugins, }; } diff --git a/packages/sdk/src/cli/shared/context.test.ts b/packages/sdk/src/cli/shared/context.test.ts index e6964d56f..f7115ac9f 100644 --- a/packages/sdk/src/cli/shared/context.test.ts +++ b/packages/sdk/src/cli/shared/context.test.ts @@ -4,6 +4,7 @@ import { parseYAML } from "confbox"; import * as path from "pathe"; import { describe, expect, test, vi, beforeEach, afterEach, afterAll, beforeAll } from "vitest"; import { + fetchLatestToken, loadConsoleBaseUrl, loadAccessToken, loadConfigPath, @@ -17,11 +18,12 @@ import { } from "./context"; import { isCLIError } from "./errors"; import { logger } from "./logger"; -import { resetKeyringState } from "./token-store"; +import { isKeyringAvailable, resetKeyringState } from "./token-store"; import type * as ClientModule from "./client"; const xdgTempDir = vi.hoisted(() => `/tmp/tailor-xdg-${Date.now()}-${Math.random()}`); -const refreshTokenMock = vi.hoisted(() => vi.fn()); +const keyringPasswords = vi.hoisted(() => new Map()); +const keyringSetPasswordFailure = vi.hoisted(() => ({ error: undefined as Error | undefined })); vi.mock("xdg-basedir", () => ({ xdgConfig: xdgTempDir, @@ -29,21 +31,36 @@ vi.mock("xdg-basedir", () => ({ vi.mock("@napi-rs/keyring", () => ({ Entry: class { - setPassword() {} + private key: string; + constructor(service: string, account: string) { + this.key = `${service}:${account}`; + } + setPassword(password: string) { + if (keyringSetPasswordFailure.error) throw keyringSetPasswordFailure.error; + keyringPasswords.set(this.key, password); + } getPassword(): string | null { - return null; + return keyringPasswords.get(this.key) ?? null; + } + deletePassword() { + keyringPasswords.delete(this.key); } - deletePassword() {} }, })); +const clientMocks = vi.hoisted(() => ({ + fetchUserInfo: vi.fn(), + refreshToken: vi.fn(), +})); + vi.mock("./client", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - initOAuth2Client: vi.fn(() => ({ - refreshToken: refreshTokenMock, - })), + fetchUserInfo: clientMocks.fetchUserInfo, + initOAuth2Client: () => ({ + refreshToken: clientMocks.refreshToken, + }), }; }); @@ -56,49 +73,12 @@ function writeFuturePlatformConfig() { ); } -type PfProfile = { - user: string; - workspace_id: string; - readonly?: boolean; - machine_user?: string; - machine_user_override?: "allow" | "deny"; -}; - -type PfUserV2 = - | { storage: "keyring"; token_expires_at: string } - | { storage: "file"; access_token: string; refresh_token?: string; token_expires_at: string }; - -type PfConfig = { - version: 2; - min_sdk_version: `${number}.${number}.${number}`; - users: Record; - profiles: Record; - current_user: string | null; -}; - -function v2Config(overrides: Partial = {}): PfConfig { - return { - version: 2, - min_sdk_version: "1.29.0", - users: {}, - profiles: {}, - current_user: null, - ...overrides, - }; -} - -function fileUser(accessToken: string, tokenExpiresAt: string): PfUserV2 { - return { - access_token: accessToken, - refresh_token: "refresh", - token_expires_at: tokenExpiresAt, - storage: "file", - }; -} - -function profile(user: string, overrides: Partial = {}): PfProfile { - return { user, workspace_id: "12345678-1234-4abc-8def-123456789012", ...overrides }; -} +beforeEach(() => { + clientMocks.fetchUserInfo.mockReset(); + clientMocks.refreshToken.mockReset(); + keyringPasswords.clear(); + keyringSetPasswordFailure.error = undefined; +}); describe("loadConfigPath", () => { const originalEnv = process.env; @@ -107,7 +87,7 @@ describe("loadConfigPath", () => { beforeEach(() => { vi.resetModules(); process.env = { ...originalEnv }; - delete process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH; + delete process.env.TAILOR_CONFIG_PATH; tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tailor-test-")); vi.spyOn(process, "cwd").mockReturnValue(tempDir); }); @@ -124,7 +104,7 @@ describe("loadConfigPath", () => { }); test("returns env config path when set", () => { - process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH = "/env/path/config.ts"; + process.env.TAILOR_CONFIG_PATH = "/env/path/config.ts"; const result = loadConfigPath(); expect(result).toBe("/env/path/config.ts"); }); @@ -137,11 +117,8 @@ describe("loadConfigPath", () => { expect(result).toBe(configPath); }); - test.each([ - ["parent", ["nested"]], - ["grandparent", ["nested", "deep"]], - ])("finds config in %s directory", (_label, segments) => { - const nestedDir = path.join(tempDir, ...segments); + test("finds config in parent directory", () => { + const nestedDir = path.join(tempDir, "nested"); fs.mkdirSync(nestedDir, { recursive: true }); const configPath = path.join(tempDir, "tailor.config.ts"); fs.writeFileSync(configPath, "export default {}"); @@ -151,6 +128,17 @@ describe("loadConfigPath", () => { expect(result).toBe(configPath); }); + test("finds config in grandparent directory", () => { + const deepNestedDir = path.join(tempDir, "nested", "deep"); + fs.mkdirSync(deepNestedDir, { recursive: true }); + const configPath = path.join(tempDir, "tailor.config.ts"); + fs.writeFileSync(configPath, "export default {}"); + + vi.spyOn(process, "cwd").mockReturnValue(deepNestedDir); + const result = loadConfigPath(); + expect(result).toBe(configPath); + }); + test("prefers config in closer directory", () => { const nestedDir = path.join(tempDir, "nested"); fs.mkdirSync(nestedDir, { recursive: true }); @@ -182,12 +170,18 @@ describe("loadWorkspaceId", () => { beforeEach(() => { vi.resetModules(); - refreshTokenMock.mockReset(); + clientMocks.refreshToken.mockReset(); resetKeyringState(); process.env = { ...originalEnv }; delete process.env.TAILOR_PLATFORM_WORKSPACE_ID; delete process.env.TAILOR_PLATFORM_PROFILE; - writePlatformConfig(v2Config()); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); }); afterEach(() => { @@ -246,9 +240,15 @@ describe("loadWorkspaceId", () => { test("env takes precedence over profile", async () => { process.env.TAILOR_PLATFORM_WORKSPACE_ID = validUUID; - writePlatformConfig( - v2Config({ profiles: { myprofile: profile("test", { workspace_id: otherUUID }) } }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + myprofile: { user: "test", workspace_id: otherUUID }, + }, + current_user: null, + }); const result = await loadWorkspaceId({ profile: "myprofile" }); expect(result).toBe(validUUID); }); @@ -266,23 +266,38 @@ describe("loadWorkspaceId", () => { describe("opts.profile", () => { test("returns workspaceId from profile when opts.profile provided", async () => { - writePlatformConfig( - v2Config({ profiles: { myprofile: profile("testuser", { workspace_id: validUUID }) } }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { myprofile: { user: "testuser", workspace_id: validUUID } }, + current_user: null, + }); const result = await loadWorkspaceId({ profile: "myprofile" }); expect(result).toBe(validUUID); }); test("throws error when profile not found", async () => { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); await expect(loadWorkspaceId({ profile: "nonexistent" })).rejects.toThrow( 'Profile "nonexistent" not found', ); }); test("throws error when profile workspace_id is invalid UUID", async () => { - writePlatformConfig( - v2Config({ profiles: { badprofile: profile("testuser", { workspace_id: invalidUUID }) } }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { badprofile: { user: "testuser", workspace_id: invalidUUID } }, + current_user: null, + }); await expect(loadWorkspaceId({ profile: "badprofile" })).rejects.toThrow( 'Invalid value from profile "badprofile": must be a valid UUID', ); @@ -292,23 +307,29 @@ describe("loadWorkspaceId", () => { describe("env.TAILOR_PLATFORM_PROFILE", () => { test("returns workspaceId from env profile when set", async () => { process.env.TAILOR_PLATFORM_PROFILE = "envprofile"; - writePlatformConfig( - v2Config({ profiles: { envprofile: profile("testuser", { workspace_id: validUUID }) } }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { envprofile: { user: "testuser", workspace_id: validUUID } }, + current_user: null, + }); const result = await loadWorkspaceId(); expect(result).toBe(validUUID); }); test("opts.profile takes precedence over env profile", async () => { process.env.TAILOR_PLATFORM_PROFILE = "envprofile"; - writePlatformConfig( - v2Config({ - profiles: { - envprofile: profile("testuser", { workspace_id: otherUUID }), - optsprofile: profile("testuser", { workspace_id: validUUID }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + envprofile: { user: "testuser", workspace_id: otherUUID }, + optsprofile: { user: "testuser", workspace_id: validUUID }, + }, + current_user: null, + }); const result = await loadWorkspaceId({ profile: "optsprofile" }); expect(result).toBe(validUUID); }); @@ -333,7 +354,13 @@ describe("loadMachineUserName", () => { resetKeyringState(); vi.stubEnv("TAILOR_PLATFORM_MACHINE_USER_NAME", undefined); vi.stubEnv("TAILOR_PLATFORM_PROFILE", undefined); - writePlatformConfig(v2Config()); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); }); afterEach(() => { @@ -359,33 +386,37 @@ describe("loadMachineUserName", () => { test("env takes precedence over profile default", async () => { vi.stubEnv("TAILOR_PLATFORM_MACHINE_USER_NAME", "env-bot"); - writePlatformConfig( - v2Config({ - profiles: { - myprofile: profile("u", { workspace_id: validUUID, machine_user: "profile-bot" }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { myprofile: { user: "u", workspace_id: validUUID, machine_user: "profile-bot" } }, + current_user: null, + }); const result = await loadMachineUserName({ profile: "myprofile" }); expect(result).toBe("env-bot"); }); test("returns machine_user from profile when profile provided", async () => { - writePlatformConfig( - v2Config({ - profiles: { - myprofile: profile("u", { workspace_id: validUUID, machine_user: "profile-bot" }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { myprofile: { user: "u", workspace_id: validUUID, machine_user: "profile-bot" } }, + current_user: null, + }); const result = await loadMachineUserName({ profile: "myprofile" }); expect(result).toBe("profile-bot"); }); test("returns undefined when profile has no machine_user", async () => { - writePlatformConfig( - v2Config({ profiles: { myprofile: profile("u", { workspace_id: validUUID }) } }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { myprofile: { user: "u", workspace_id: validUUID } }, + current_user: null, + }); const result = await loadMachineUserName({ profile: "myprofile" }); expect(result).toBeUndefined(); }); @@ -403,13 +434,15 @@ describe("loadMachineUserName", () => { test("returns machine_user from env profile when TAILOR_PLATFORM_PROFILE is set", async () => { vi.stubEnv("TAILOR_PLATFORM_PROFILE", "envprofile"); - writePlatformConfig( - v2Config({ - profiles: { - envprofile: profile("u", { workspace_id: validUUID, machine_user: "env-profile-bot" }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + envprofile: { user: "u", workspace_id: validUUID, machine_user: "env-profile-bot" }, + }, + current_user: null, + }); const result = await loadMachineUserName(); expect(result).toBe("env-profile-bot"); }); @@ -419,17 +452,20 @@ describe("loadMachineUserName", () => { 'The machine user is being set to "other-bot" via the TAILOR_PLATFORM_MACHINE_USER_NAME environment variable, which conflicts with this profile\'s pinned machine user "profile-bot".'; beforeEach(() => { - writePlatformConfig( - v2Config({ - profiles: { - locked: profile("u", { - workspace_id: validUUID, - machine_user: "profile-bot", - machine_user_override: "deny", - }), + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + locked: { + user: "u", + workspace_id: validUUID, + machine_user: "profile-bot", + machine_user_override: "deny", }, - }), - ); + }, + current_user: null, + }); }); test("rejects with PROFILE_MACHINE_USER_OVERRIDE_DENIED when opts.machineUser differs", async () => { @@ -492,13 +528,15 @@ describe("loadMachineUserName", () => { }); test("explicit value wins over profile when profile has machine_user but no override (regression guard)", async () => { - writePlatformConfig( - v2Config({ - profiles: { - myprofile: profile("u", { workspace_id: validUUID, machine_user: "profile-bot" }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + myprofile: { user: "u", workspace_id: validUUID, machine_user: "profile-bot" }, + }, + current_user: null, + }); const result = await loadMachineUserName({ machineUser: "explicit-bot", profile: "myprofile" }); expect(result).toBe("explicit-bot"); }); @@ -516,13 +554,13 @@ describe("loadMachineUserName", () => { }); test("rejects empty opts.machineUser instead of falling back to profile default", async () => { - writePlatformConfig( - v2Config({ - profiles: { - myprofile: profile("u", { workspace_id: validUUID, machine_user: "profile-bot" }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { myprofile: { user: "u", workspace_id: validUUID, machine_user: "profile-bot" } }, + current_user: null, + }); const err = await loadMachineUserName({ machineUser: "", profile: "myprofile" }).catch( (e: unknown) => e, ); @@ -535,6 +573,7 @@ describe("loadAccessToken", () => { const validToken = "valid-access-token"; const otherToken = "other-access-token"; const futureDate = new Date(Date.now() + 3600 * 1000).toISOString(); + const pastDate = new Date(Date.now() - 3600 * 1000).toISOString(); beforeEach(() => { vi.resetModules(); @@ -547,7 +586,13 @@ describe("loadAccessToken", () => { vi.stubEnv("TAILOR_PLATFORM_PROFILE", undefined); vi.stubEnv("TAILOR_PLATFORM_URL", undefined); vi.stubEnv("TAILOR_PLATFORM_OAUTH2_CLIENT_ID", undefined); - writePlatformConfig(v2Config()); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); }); describe("env.TAILOR_PLATFORM_TOKEN", () => { @@ -576,23 +621,32 @@ describe("loadAccessToken", () => { test("TAILOR_PLATFORM_TOKEN takes precedence over profile", async () => { vi.stubEnv("TAILOR_PLATFORM_TOKEN", validToken); - writePlatformConfig( - v2Config({ - users: { testuser: fileUser(otherToken, futureDate) }, - profiles: { myprofile: profile("testuser") }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + testuser: { + access_token: otherToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", + }, + }, + profiles: { + myprofile: { user: "testuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: null, + }); const result = await loadAccessToken({ profile: "myprofile" }); expect(result).toBe(validToken); }); }); - describe("env.TAILOR_TOKEN (deprecated)", () => { - test("returns token from TAILOR_TOKEN when TAILOR_PLATFORM_TOKEN not set", async () => { + describe("env.TAILOR_TOKEN", () => { + test("uses the deprecated TAILOR_TOKEN fallback", async () => { vi.stubEnv("TAILOR_TOKEN", validToken); using warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); - const result = await loadAccessToken(); - expect(result).toBe(validToken); + await expect(loadAccessToken()).resolves.toBe(validToken); expect(warnSpy).toHaveBeenCalledWith( "TAILOR_TOKEN is deprecated. Please use TAILOR_PLATFORM_TOKEN instead.", ); @@ -601,33 +655,62 @@ describe("loadAccessToken", () => { describe("opts.profile", () => { test("returns token from profile when profile provided", async () => { - writePlatformConfig( - v2Config({ - users: { testuser: fileUser(validToken, futureDate) }, - profiles: { myprofile: profile("testuser") }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + testuser: { + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", + }, + }, + profiles: { + myprofile: { user: "testuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: null, + }); const result = await loadAccessToken({ profile: "myprofile" }); expect(result).toBe(validToken); }); test("throws error when profile not found", async () => { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); await expect(loadAccessToken({ profile: "nonexistent" })).rejects.toThrow( 'Profile "nonexistent" not found', ); }); test("prefers the profile user over current_user", async () => { - writePlatformConfig( - v2Config({ - users: { - currentuser: fileUser(validToken, futureDate), - profileuser: fileUser(otherToken, futureDate), + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + currentuser: { + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", }, - profiles: { myprofile: profile("profileuser") }, - current_user: "currentuser", - }), - ); + profileuser: { + access_token: otherToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", + }, + }, + profiles: { + myprofile: { user: "profileuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: "currentuser", + }); const result = await loadAccessToken({ profile: "myprofile" }); expect(result).toBe(otherToken); }); @@ -655,7 +738,7 @@ describe("loadAccessToken", () => { refreshToken: "refresh", }, futureDate, - { platformUrl: "https://api.dev.tailor.tech" }, + { platformConfig: { platformUrl: "https://api.dev.tailor.tech" } }, ); writePlatformConfig(config); @@ -725,7 +808,7 @@ describe("loadAccessToken", () => { vi.stubEnv("TAILOR_PLATFORM_URL", "https://api.dev.tailor.tech"); const pastDate = new Date(Date.now() - 3600 * 1000).toISOString(); const refreshedExpiresAt = Date.now() + 3600 * 1000; - refreshTokenMock.mockResolvedValueOnce({ + clientMocks.refreshToken.mockResolvedValueOnce({ accessToken: "refreshed-token", refreshToken: "refreshed-refresh", expiresAt: refreshedExpiresAt, @@ -751,41 +834,63 @@ describe("loadAccessToken", () => { const updatedConfig = await readPlatformConfig(); expect(updatedConfig.users.testuser).toBeUndefined(); expect(updatedConfig.users["https://api.dev.tailor.tech|testuser"]).toMatchObject({ - storage: "file", - access_token: "refreshed-token", - refresh_token: "refreshed-refresh", + storage: "keyring", token_expires_at: new Date(refreshedExpiresAt).toISOString(), }); + expect(keyringPasswords.get("tailor-platform-cli:https://api.dev.tailor.tech|testuser")).toBe( + JSON.stringify({ accessToken: "refreshed-token", refreshToken: "refreshed-refresh" }), + ); }); }); describe("env.TAILOR_PLATFORM_PROFILE", () => { test("returns token from env profile", async () => { vi.stubEnv("TAILOR_PLATFORM_PROFILE", "envprofile"); - writePlatformConfig( - v2Config({ - users: { testuser: fileUser(validToken, futureDate) }, - profiles: { envprofile: profile("testuser") }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + testuser: { + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", + }, + }, + profiles: { + envprofile: { user: "testuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: null, + }); const result = await loadAccessToken(); expect(result).toBe(validToken); }); test("opts.profile takes precedence over env profile", async () => { vi.stubEnv("TAILOR_PLATFORM_PROFILE", "envprofile"); - writePlatformConfig( - v2Config({ - users: { - envuser: fileUser(otherToken, futureDate), - optsuser: fileUser(validToken, futureDate), + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + envuser: { + access_token: otherToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", }, - profiles: { - envprofile: profile("envuser"), - optsprofile: profile("optsuser"), + optsuser: { + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", }, - }), - ); + }, + profiles: { + envprofile: { user: "envuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + optsprofile: { user: "optsuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: null, + }); const result = await loadAccessToken({ profile: "optsprofile" }); expect(result).toBe(validToken); }); @@ -793,15 +898,175 @@ describe("loadAccessToken", () => { describe("config.current_user", () => { test("returns token from current_user when no env or profile", async () => { - writePlatformConfig( - v2Config({ - users: { currentuser: fileUser(validToken, futureDate) }, - current_user: "currentuser", - }), - ); - const result = await loadAccessToken(); - expect(result).toBe(validToken); - }); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + currentuser: { + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", + }, + }, + profiles: {}, + current_user: "currentuser", + }); + const result = await loadAccessToken(); + expect(result).toBe(validToken); + }); + + test("fetchLatestToken resolves a subject-keyed user by email metadata", async () => { + writePlatformConfig({ + version: 3, + min_sdk_version: "2.0.0", + users: { + "platform-user-sub": { + storage: "file", + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + email: "user@example.com", + }, + }, + profiles: {}, + current_user: "platform-user-sub", + }); + + const config = await readPlatformConfig(); + await expect(fetchLatestToken(config, "user@example.com")).resolves.toEqual({ + accessToken: validToken, + user: "platform-user-sub", + }); + }); + + test("refreshes a legacy email-key user into a subject-key V3 config", async () => { + clientMocks.refreshToken.mockResolvedValue({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: Date.now() + 3600 * 1000, + }); + clientMocks.fetchUserInfo.mockResolvedValue({ + sub: "platform-user-sub", + email: "legacy@example.com", + }); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "legacy@example.com": { + access_token: "expired-access-token", + refresh_token: "refresh", + token_expires_at: pastDate, + storage: "file", + }, + }, + profiles: { + default: { + user: "legacy@example.com", + workspace_id: "12345678-1234-4abc-8def-123456789012", + }, + }, + current_user: "legacy@example.com", + }); + + const token = await loadAccessToken(); + const config = await readPlatformConfig(); + + expect(token).toBe("new-access-token"); + expect(clientMocks.fetchUserInfo).toHaveBeenCalledWith("new-access-token", undefined); + expect(config.version).toBe(3); + expect(config.users["legacy@example.com"]).toBeUndefined(); + expect(config.users["platform-user-sub"]).toMatchObject({ + storage: "keyring", + email: "legacy@example.com", + }); + expect(keyringPasswords.get("tailor-platform-cli:platform-user-sub")).toBe( + JSON.stringify({ accessToken: "new-access-token", refreshToken: "new-refresh-token" }), + ); + expect(config.current_user).toBe("platform-user-sub"); + expect(config.profiles.default?.user).toBe("platform-user-sub"); + }); + + test("logs when refresh updates the stored user email", async () => { + clientMocks.refreshToken.mockResolvedValue({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: Date.now() + 3600 * 1000, + }); + clientMocks.fetchUserInfo.mockResolvedValue({ + sub: "platform-user-sub", + email: "new@example.com", + }); + writePlatformConfig({ + version: 3, + min_sdk_version: "2.0.0", + users: { + "platform-user-sub": { + access_token: "expired-access-token", + refresh_token: "refresh", + token_expires_at: pastDate, + storage: "file", + email: "old@example.com", + }, + }, + profiles: {}, + current_user: "platform-user-sub", + }); + + const config = await readPlatformConfig(); + using infoSpy = vi.spyOn(logger, "info").mockImplementation(() => {}); + + await fetchLatestToken(config, "platform-user-sub"); + + expect(infoSpy).toHaveBeenCalledWith( + 'Updated local user email from "old@example.com" to "new@example.com".', + ); + expect(config.users["platform-user-sub"]?.email).toBe("new@example.com"); + }); + + test("keeps the legacy email key when subject resolution fails on refresh", async () => { + clientMocks.refreshToken.mockResolvedValue({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: Date.now() + 3600 * 1000, + }); + clientMocks.fetchUserInfo.mockRejectedValue(new Error("network down")); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "legacy@example.com": { + access_token: "expired-access-token", + refresh_token: "refresh", + token_expires_at: pastDate, + storage: "file", + }, + }, + profiles: { + default: { + user: "legacy@example.com", + workspace_id: "12345678-1234-4abc-8def-123456789012", + }, + }, + current_user: "legacy@example.com", + }); + + const token = await loadAccessToken(); + const config = await readPlatformConfig(); + + expect(token).toBe("new-access-token"); + expect(clientMocks.fetchUserInfo).toHaveBeenCalledWith("new-access-token", undefined); + expect(config.users["platform-user-sub"]).toBeUndefined(); + expect(config.users["legacy@example.com"]).toMatchObject({ + storage: "keyring", + }); + expect(keyringPasswords.get("tailor-platform-cli:legacy@example.com")).toBe( + JSON.stringify({ accessToken: "new-access-token", refreshToken: "new-refresh-token" }), + ); + expect(config.current_user).toBe("legacy@example.com"); + expect(config.profiles.default?.user).toBe("legacy@example.com"); + }); }); describe("error case: no token source", () => { @@ -887,14 +1152,20 @@ describe("profile readonly field", () => { }); test("round-trips readonly: true through write/read", async () => { - writePlatformConfig( - v2Config({ - profiles: { - ro: profile("u@example.com", { readonly: true }), - rw: profile("u@example.com"), + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + ro: { + user: "u@example.com", + workspace_id: "12345678-1234-4abc-8def-123456789012", + readonly: true, }, - }), - ); + rw: { user: "u@example.com", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: null, + }); const { readPlatformConfig } = await import("./context"); const config = await readPlatformConfig(); expect(config.profiles.ro?.readonly).toBe(true); @@ -902,14 +1173,195 @@ describe("profile readonly field", () => { }); }); -describe("V1 to V2 migration", () => { +describe("initial platform config", () => { + const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); + const legacyHomeDir = path.join(xdgTempDir, "legacy-home"); + const legacyConfigPath = path.join(legacyHomeDir, ".tailorctl", "config"); + + beforeEach(() => { + vi.resetModules(); + resetKeyringState(); + vi.stubEnv("HOME", legacyHomeDir); + fs.rmSync(configPath, { force: true }); + fs.rmSync(legacyHomeDir, { recursive: true, force: true }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test("creates an empty latest-version config when the platform config is missing", async () => { + const config = await readPlatformConfig(); + + expect(config).toEqual({ + version: 3, + min_sdk_version: "2.0.0", + users: {}, + profiles: {}, + current_user: null, + }); + expect(parseYAML(fs.readFileSync(configPath, "utf-8"))).toEqual(config); + }); + + test("ignores legacy tailorctl config when the platform config is missing", async () => { + fs.mkdirSync(path.dirname(legacyConfigPath), { recursive: true }); + fs.writeFileSync( + legacyConfigPath, + [ + "[global]", + 'context = "default"', + "", + "[default]", + 'username = "user@example.com"', + 'workspaceid = "12345678-1234-4abc-8def-123456789012"', + 'controlplaneaccesstoken = "legacy-access-token"', + 'controlplanerefreshtoken = "legacy-refresh-token"', + 'controlplanetokenexpiresat = "2099-01-01T00:00:00.000Z"', + "", + ].join("\n"), + ); + + const config = await readPlatformConfig(); + + expect(config.users).toEqual({}); + expect(config.profiles).toEqual({}); + expect(config.current_user).toBeNull(); + }); +}); + +describe("saveUserTokens", () => { + const futureDate = new Date(Date.now() + 3600 * 1000).toISOString(); + const originalEnv = process.env; + type PlatformConfig = Parameters[0]; + + function createEmptyConfig(): PlatformConfig { + return { + version: 3, + min_sdk_version: "2.0.0", + users: {}, + profiles: {}, + current_user: null, + }; + } + + beforeEach(() => { + vi.resetModules(); + resetKeyringState(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test("stores tokens in the OS keyring by default when available", async () => { + const config = createEmptyConfig(); + + await saveUserTokens( + config, + "platform-user-sub", + { accessToken: "access-token", refreshToken: "refresh-token" }, + futureDate, + { email: "user@example.com" }, + ); + + expect(config.users["platform-user-sub"]).toEqual({ + storage: "keyring", + token_expires_at: futureDate, + email: "user@example.com", + }); + expect(keyringPasswords.get("tailor-platform-cli:platform-user-sub")).toBe( + JSON.stringify({ accessToken: "access-token", refreshToken: "refresh-token" }), + ); + }); + + test.each(["0", "false", "off"])( + "ignores TAILOR_USE_KEYRING=%s and stores tokens in the OS keyring", + async (value) => { + process.env.TAILOR_USE_KEYRING = value; + const config = createEmptyConfig(); + + await saveUserTokens( + config, + "platform-user-sub", + { accessToken: "access-token", refreshToken: "refresh-token" }, + futureDate, + ); + + expect(config.users["platform-user-sub"]).toEqual({ + storage: "keyring", + token_expires_at: futureDate, + }); + expect(keyringPasswords.get("tailor-platform-cli:platform-user-sub")).toBe( + JSON.stringify({ accessToken: "access-token", refreshToken: "refresh-token" }), + ); + }, + ); + + test("falls back to the config file when keyring storage fails", async () => { + const config = createEmptyConfig(); + + expect(await isKeyringAvailable()).toBe(true); + keyringSetPasswordFailure.error = new Error("keyring denied"); + using warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + + await saveUserTokens( + config, + "platform-user-sub", + { accessToken: "access-token", refreshToken: "refresh-token" }, + futureDate, + ); + + expect(config.users["platform-user-sub"]).toEqual({ + storage: "file", + access_token: "access-token", + refresh_token: "refresh-token", + token_expires_at: futureDate, + }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("keyring denied")); + }); + + test("deletes stale keyring tokens when falling back to the config file", async () => { + const config = createEmptyConfig(); + config.users["platform-user-sub"] = { + storage: "keyring", + token_expires_at: futureDate, + }; + keyringPasswords.set( + "tailor-platform-cli:platform-user-sub", + JSON.stringify({ accessToken: "stale-access-token", refreshToken: "stale-refresh-token" }), + ); + + expect(await isKeyringAvailable()).toBe(true); + keyringSetPasswordFailure.error = new Error("keyring denied"); + using warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + + await saveUserTokens( + config, + "platform-user-sub", + { accessToken: "access-token", refreshToken: "refresh-token" }, + futureDate, + ); + + expect(config.users["platform-user-sub"]).toEqual({ + storage: "file", + access_token: "access-token", + refresh_token: "refresh-token", + token_expires_at: futureDate, + }); + expect(keyringPasswords.has("tailor-platform-cli:platform-user-sub")).toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("keyring denied")); + }); +}); + +describe("V1 to V3 migration", () => { const futureDate = new Date(Date.now() + 3600 * 1000).toISOString(); beforeEach(() => { resetKeyringState(); }); - test("migrates V1 config to V2 in memory without rewriting disk", async () => { + test("migrates V1 config to V3 in memory without rewriting disk", async () => { const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); writePlatformConfig({ version: 1, @@ -921,7 +1373,7 @@ describe("V1 to V2 migration", () => { }, }, profiles: { - default: profile("user@example.com"), + default: { user: "user@example.com", workspace_id: "12345678-1234-4abc-8def-123456789012" }, }, current_user: "user@example.com", }); @@ -930,11 +1382,12 @@ describe("V1 to V2 migration", () => { const { readPlatformConfig } = await import("./context"); const config = await readPlatformConfig(); - // In-memory: V2 with storage: "file" - expect(config.version).toBe(2); + // In-memory: V3 with storage: "file" and inferred legacy email metadata. + expect(config.version).toBe(3); const userEntry = config.users["user@example.com"]; expect(userEntry).toBeDefined(); expect(userEntry!.storage).toBe("file"); + expect(userEntry!.email).toBe("user@example.com"); expect(userEntry!.token_expires_at).toBe(futureDate); if (userEntry!.storage !== "file") { throw new Error("Expected file-backed user entry"); @@ -942,7 +1395,7 @@ describe("V1 to V2 migration", () => { expect(userEntry!.access_token).toBe("v1-access-token"); expect(userEntry!.refresh_token).toBe("v1-refresh-token"); - // Disk: still V1 (not rewritten to V2) + // Disk: still V1 (not rewritten to V3) const diskConfig = parseYAML(fs.readFileSync(configPath, "utf-8")) as { version: number }; expect(diskConfig.version).toBe(1); }); @@ -957,30 +1410,28 @@ describe("keyring user persistence on V2 -> V1 downgrade", () => { vi.resetModules(); resetKeyringState(); process.env = { ...originalEnv }; - // Downgrade only happens when TAILOR_USE_KEYRING is unset, which is the - // default for every command that is not opting into keyring storage. - delete process.env.TAILOR_USE_KEYRING; }); afterEach(() => { process.env = originalEnv; }); - test("keeps the config V2 and preserves the keyring user when written without TAILOR_USE_KEYRING", async () => { - writePlatformConfig( - v2Config({ - users: { - "keyring@example.com": { storage: "keyring", token_expires_at: futureDate }, - "file@example.com": { - storage: "file", - access_token: "file-access-token", - refresh_token: "file-refresh-token", - token_expires_at: futureDate, - }, + test("keeps the config V2 and preserves the keyring user", async () => { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "keyring@example.com": { storage: "keyring", token_expires_at: futureDate }, + "file@example.com": { + storage: "file", + access_token: "file-access-token", + refresh_token: "file-refresh-token", + token_expires_at: futureDate, }, - current_user: "keyring@example.com", - }), - ); + }, + profiles: {}, + current_user: "keyring@example.com", + }); // Disk: stays V2 so the keyring entry is not dropped. const diskConfig = parseYAML(fs.readFileSync(configPath, "utf-8")) as { @@ -993,27 +1444,66 @@ describe("keyring user persistence on V2 -> V1 downgrade", () => { expect(diskConfig.users["file@example.com"]?.storage).toBe("file"); expect(diskConfig.current_user).toBe("keyring@example.com"); - // Round trip: the keyring user (and current_user) survive a re-read. + // Round trip: the keyring user (and current_user) survive a re-read and + // are exposed through the latest in-memory config version. const { readPlatformConfig } = await import("./context"); const config = await readPlatformConfig(); - expect(config.version).toBe(2); + expect(config.version).toBe(3); expect(config.users["keyring@example.com"]?.storage).toBe("keyring"); + expect(config.users["keyring@example.com"]?.email).toBe("keyring@example.com"); expect(config.current_user).toBe("keyring@example.com"); }); - test("still downgrades a file-only config to V1 for backward compatibility", () => { - writePlatformConfig( - v2Config({ - users: { - "file@example.com": { - storage: "file", - access_token: "file-access-token", - token_expires_at: futureDate, - }, + test("keeps V3 configs as V3 because subject IDs and email metadata cannot downgrade", () => { + writePlatformConfig({ + version: 3, + min_sdk_version: "2.0.0", + users: { + "platform-user-sub": { + storage: "file", + access_token: "file-access-token", + refresh_token: "file-refresh-token", + token_expires_at: futureDate, + email: "user@example.com", }, - current_user: "file@example.com", - }), - ); + }, + profiles: { + default: { + user: "platform-user-sub", + workspace_id: "12345678-1234-4abc-8def-123456789012", + }, + }, + current_user: "platform-user-sub", + }); + + const diskConfig = parseYAML(fs.readFileSync(configPath, "utf-8")) as { + version: number; + users: Record; + profiles: Record; + current_user: string | null; + }; + expect(diskConfig.version).toBe(3); + expect(diskConfig.users["platform-user-sub"]?.email).toBe("user@example.com"); + expect(diskConfig.profiles.default?.user).toBe("platform-user-sub"); + expect(diskConfig.current_user).toBe("platform-user-sub"); + }); + + test("ignores TAILOR_USE_KEYRING and still downgrades a file-only config to V1", () => { + process.env.TAILOR_USE_KEYRING = "1"; + + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "file@example.com": { + storage: "file", + access_token: "file-access-token", + token_expires_at: futureDate, + }, + }, + profiles: {}, + current_user: "file@example.com", + }); const diskConfig = parseYAML(fs.readFileSync(configPath, "utf-8")) as { version: number; @@ -1065,7 +1555,7 @@ describe("keyring user persistence on V2 -> V1 downgrade", () => { >; }; expect(diskConfig.version).toBe(3); - expect(diskConfig.min_sdk_version).toBe("1.70.0"); + expect(diskConfig.min_sdk_version).toBe("2.0.0"); expect(diskConfig.profiles.dev?.platform_url).toBe("https://api.dev.tailor.tech"); expect(diskConfig.profiles.dev?.oauth2_client_id).toBe("dev-client"); expect(diskConfig.profiles.dev?.console_url).toBe("https://console.dev.tailor.tech"); @@ -1076,20 +1566,21 @@ describe("keyring user persistence on V2 -> V1 downgrade", () => { }); test("clears current_user on V1 downgrade when it points at a user not representable in V1", () => { - writePlatformConfig( - v2Config({ - users: { - "file@example.com": { - storage: "file", - access_token: "file-access-token", - token_expires_at: futureDate, - }, + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "file@example.com": { + storage: "file", + access_token: "file-access-token", + token_expires_at: futureDate, }, - // current_user references a user that is not in the users map, so it - // cannot be represented in V1 and must be cleared on downgrade. - current_user: "missing@example.com", - }), - ); + }, + profiles: {}, + // current_user references a user that is not in the users map, so it + // cannot be represented in V1 and must be cleared on downgrade. + current_user: "missing@example.com", + }); const diskConfig = parseYAML(fs.readFileSync(configPath, "utf-8")) as { version: number; @@ -1102,7 +1593,13 @@ describe("keyring user persistence on V2 -> V1 downgrade", () => { describe.skipIf(process.platform === "win32")("writePlatformConfig file permissions", () => { test("writes the config file with mode 0600 and its directory with mode 0700", () => { - writePlatformConfig(v2Config()); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); const fileMode = fs.statSync(configPath).mode & 0o777; @@ -1118,7 +1615,13 @@ describe.skipIf(process.platform === "win32")("writePlatformConfig file permissi fs.writeFileSync(configPath, "stale: true", { mode: 0o644 }); fs.chmodSync(configPath, 0o644); - writePlatformConfig(v2Config()); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); const fileMode = fs.statSync(configPath).mode & 0o777; expect(fileMode).toBe(0o600); diff --git a/packages/sdk/src/cli/shared/context.ts b/packages/sdk/src/cli/shared/context.ts index e0a164666..a0cb97bad 100644 --- a/packages/sdk/src/cli/shared/context.ts +++ b/packages/sdk/src/cli/shared/context.ts @@ -1,6 +1,5 @@ import * as fs from "node:fs"; -import * as os from "node:os"; -import { parseYAML, stringifyYAML, parseTOML } from "confbox"; +import { parseYAML, stringifyYAML } from "confbox"; import { findUpSync } from "find-up-simple"; import * as path from "pathe"; import { lt as semverLt } from "semver"; @@ -11,6 +10,7 @@ import ml from "#/utils/multiline"; import { type MachineUserInputSource } from "./args"; import { defaultPlatformBaseUrl, + fetchUserInfo, getConsoleBaseUrl, getPlatformBaseUrl, initOAuth2Client, @@ -29,6 +29,7 @@ import { deleteKeyringTokens, } from "./token-store"; +// strip unknown keys const pfProfileSchema = z.object({ user: z.string(), workspace_id: z.string(), @@ -40,17 +41,20 @@ const pfProfileSchema = z.object({ console_url: z.url().optional(), }); +// strip unknown keys const pfUserSchemaV1 = z.object({ access_token: z.string(), refresh_token: z.string().optional(), token_expires_at: z.string(), }); +// strip unknown keys const pfUserKeyringSchema = z.object({ storage: z.literal("keyring"), token_expires_at: z.string(), }); +// strip unknown keys const pfUserFileSchema = z.object({ storage: z.literal("file"), token_expires_at: z.string(), @@ -60,8 +64,17 @@ const pfUserFileSchema = z.object({ const pfUserSchemaV2 = z.discriminatedUnion("storage", [pfUserKeyringSchema, pfUserFileSchema]); -type PfUserV2 = z.output; +const pfUserKeyringSchemaV3 = pfUserKeyringSchema.extend({ + email: z.string().optional(), +}); + +const pfUserFileSchemaV3 = pfUserFileSchema.extend({ + email: z.string().optional(), +}); + +const pfUserSchemaV3 = z.discriminatedUnion("storage", [pfUserKeyringSchemaV3, pfUserFileSchemaV3]); +// strip unknown keys const pfConfigSchemaV1 = z.object({ version: z.literal(1), users: z.partialRecord(z.string(), pfUserSchemaV1), @@ -72,7 +85,7 @@ const pfConfigSchemaV1 = z.object({ const V2_CONFIG_VERSION = 2; const LATEST_CONFIG_VERSION = 3; const V2_MIN_SDK_VERSION = "1.29.0"; -const V3_MIN_SDK_VERSION = "1.70.0"; +const V3_MIN_SDK_VERSION = "2.0.0"; const semverSchema = z.templateLiteral([ z.number().int(), @@ -82,8 +95,9 @@ const semverSchema = z.templateLiteral([ z.number().int(), ]); -const pfConfigSchema = z.object({ - version: z.union([z.literal(V2_CONFIG_VERSION), z.literal(LATEST_CONFIG_VERSION)]), +// strip unknown keys +const pfConfigSchemaV2 = z.object({ + version: z.literal(V2_CONFIG_VERSION), min_sdk_version: semverSchema, latest_version: z.number().int().optional(), latest_min_sdk_version: semverSchema.optional(), @@ -92,10 +106,23 @@ const pfConfigSchema = z.object({ current_user: z.string().nullable(), }); +// strip unknown keys +const pfConfigSchemaV3 = z.object({ + version: z.literal(LATEST_CONFIG_VERSION), + min_sdk_version: semverSchema, + latest_version: z.number().int().optional(), + latest_min_sdk_version: semverSchema.optional(), + users: z.partialRecord(z.string(), pfUserSchemaV3), + profiles: z.partialRecord(z.string(), pfProfileSchema), + current_user: z.string().nullable(), +}); + type PfConfigV1 = z.output; -type PfConfig = z.output; -type PfConfigV2 = PfConfig & { version: typeof V2_CONFIG_VERSION }; -type PfConfigV3 = PfConfig & { version: typeof LATEST_CONFIG_VERSION }; +type PfConfigV2 = z.output; +type PfConfig = z.output; +type PfUser = z.output; +type UserTokens = { accessToken: string; refreshToken?: string }; +type PfConfigV3 = PfConfig; type LoadWorkspaceIdOptions = { workspaceId?: string; profile?: string; @@ -158,6 +185,11 @@ function platformUserKey(user: string, config?: PlatformClientConfig): string { return `${platformUrl}|${user}`; } +function userFromPlatformUserKey(userKey: string, config?: PlatformClientConfig): string { + const platformPrefix = `${getPlatformBaseUrl(config)}|`; + return userKey.startsWith(platformPrefix) ? userKey.slice(platformPrefix.length) : userKey; +} + function canUseLegacyUserKey(platformUrl: string): boolean { try { return getPlatformBaseUrl() === platformUrl; @@ -170,6 +202,20 @@ type UserEntryLookupOptions = { allowLegacyUserKey?: boolean; }; +function findUserByEmail( + users: PfConfig["users"], + email: string, + platformConfig?: PlatformClientConfig, +) { + const platformUrl = getPlatformBaseUrl(platformConfig); + const platformPrefix = `${platformUrl}|`; + const defaultPlatform = platformUrl === normalizeBaseUrl(defaultPlatformBaseUrl); + return Object.entries(users).find(([key, entry]) => { + if (entry?.email !== email) return false; + return defaultPlatform ? !key.includes("|") : key.startsWith(platformPrefix); + }); +} + function findUserEntry( config: PfConfig, user: string, @@ -181,6 +227,10 @@ function findUserEntry( if (userEntry) { return { userKey, userEntry }; } + const emailMatch = findUserByEmail(config.users, user, platformConfig); + if (emailMatch?.[1]) { + return { userKey: emailMatch[0], userEntry: emailMatch[1] }; + } const platformUrl = getPlatformBaseUrl(platformConfig); if ( userKey !== user && @@ -209,6 +259,16 @@ export function resolveUserTokenKey( return findUserEntry(config, user, platformConfig, opts).userKey; } +export function resolveConfigUser( + config: PfConfig, + user: string, + platformConfig?: PlatformClientConfig, + opts?: UserEntryLookupOptions, +): string | undefined { + const { userKey, userEntry } = findUserEntry(config, user, platformConfig, opts); + return userEntry ? userFromPlatformUserKey(userKey, platformConfig) : undefined; +} + /** * Check whether tokens are registered for a user on the selected platform. * @param config - Platform config @@ -230,6 +290,10 @@ function hasUserKeyForName(users: Record, user: string): boolea return users[user] !== undefined || Object.keys(users).some((key) => key.endsWith(`|${user}`)); } +function hasUserEmailEntry(users: PfConfig["users"], user: string): boolean { + return Object.values(users).some((entry) => entry?.email === user); +} + /** * Check whether any platform has tokens registered for a user. * @param config - Platform config @@ -237,7 +301,7 @@ function hasUserKeyForName(users: Record, user: string): boolea * @returns True when the user has a token entry for any platform */ export function hasAnyUserTokenEntry(config: Pick, user: string): boolean { - return hasUserKeyForName(config.users, user); + return hasUserKeyForName(config.users, user) || hasUserEmailEntry(config.users, user); } function hasCurrentUserEntry(users: PfConfigV1["users"], currentUser: string): boolean { @@ -252,7 +316,7 @@ function hasCurrentUserEntry(users: PfConfigV1["users"], currentUser: string): b * @returns Migrated v2 configuration */ function migrateV1ToV2(v1Config: PfConfigV1): PfConfigV2 { - const users: PfConfig["users"] = {}; + const users: PfConfigV2["users"] = {}; for (const [name, v1User] of Object.entries(v1Config.users)) { if (!v1User) continue; @@ -274,7 +338,56 @@ function migrateV1ToV2(v1Config: PfConfigV1): PfConfigV2 { }; } -async function warnIfNewerConfigAvailable(config: PfConfig) { +function inferEmailFromUserId(user: string): string | undefined { + return z.email().safeParse(user).success ? user : undefined; +} + +function migrateV2ToV3(v2Config: PfConfigV2): PfConfig { + const users: PfConfig["users"] = {}; + + for (const [user, entry] of Object.entries(v2Config.users)) { + if (!entry) continue; + const email = inferEmailFromUserId(user); + users[user] = { + ...entry, + ...(email ? { email } : {}), + }; + } + + return { + version: LATEST_CONFIG_VERSION, + min_sdk_version: V3_MIN_SDK_VERSION, + users, + profiles: v2Config.profiles, + current_user: v2Config.current_user, + }; +} + +function migrateV1ToV3(v1Config: PfConfigV1): PfConfig { + return migrateV2ToV3(migrateV1ToV2(v1Config)); +} + +function formatUnknownError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function trySaveTokensInKeyring(user: string, tokens: UserTokens): Promise { + if (!(await isKeyringAvailable())) return false; + try { + await saveKeyringTokens(user, tokens); + return true; + } catch (error) { + logger.warn( + `System keyring failed to store credentials. Tokens will be stored in the config file. ${formatUnknownError(error)}`, + ); + return false; + } +} + +async function warnIfNewerConfigAvailable(config: { + latest_version?: number; + latest_min_sdk_version?: string; +}) { if (!config.latest_min_sdk_version) return; const packageJson = await readPackageJson(); const sdkVersion = packageJson.version ?? "0.0.0"; @@ -287,21 +400,22 @@ async function warnIfNewerConfigAvailable(config: PfConfig) { } /** - * Read Tailor Platform CLI configuration, migrating from tailorctl or v1 if necessary. + * Read Tailor Platform CLI configuration, migrating from v1 if necessary. * @returns Parsed platform configuration */ export async function readPlatformConfig(): Promise { const configPath = platformConfigPath(); - // If platform config doesn't exist, try to read tailorctl config and migrate if (!fs.existsSync(configPath)) { - logger.warn(`Config not found at ${configPath}, migrating from tailorctl config...`); - const tcConfig = readTailorctlConfig(); - const v1Config = tcConfig - ? fromTailorctlConfig(tcConfig) - : ({ version: 1, users: {}, profiles: {}, current_user: null } as const); - writePlatformConfig(v1Config); - return migrateV1ToV2(v1Config); + const config: PfConfig = { + version: LATEST_CONFIG_VERSION, + min_sdk_version: V3_MIN_SDK_VERSION, + users: {}, + profiles: {}, + current_user: null, + }; + writePlatformConfig(config); + return config; } const rawConfig = parseYAML(fs.readFileSync(configPath, "utf-8")); @@ -330,26 +444,34 @@ export async function readPlatformConfig(): Promise { `); } - const configResult = pfConfigSchema.safeParse(rawConfig); - if (configResult.success) { - await warnIfNewerConfigAvailable(configResult.data); - return configResult.data; + // Try v3 first + const v3Result = pfConfigSchemaV3.safeParse(rawConfig); + if (v3Result.success) { + await warnIfNewerConfigAvailable(v3Result.data); + return v3Result.data; } - // Fall back to v1 (convert to v2 in memory, but don't rewrite disk) + // Try v2 next + const v2Result = pfConfigSchemaV2.safeParse(rawConfig); + if (v2Result.success) { + await warnIfNewerConfigAvailable(v2Result.data); + return migrateV2ToV3(v2Result.data); + } + + // Fall back to v1 (convert to v3 in memory, but don't rewrite disk) const v1Result = pfConfigSchemaV1.safeParse(rawConfig); if (v1Result.success) { - return migrateV1ToV2(v1Result.data); + return migrateV1ToV3(v1Result.data); } - // Neither v1, v2, nor the latest format + // Neither v1, v2, nor v3 throw new Error(ml` Failed to parse config file at ${configPath}. The file may be corrupted or created by an incompatible SDK version. `); } -function toV1ForDisk(config: PfConfig): PfConfigV1 { +function toV1ForDisk(config: PfConfigV2): PfConfigV1 { const users: PfConfigV1["users"] = {}; for (const [name, entry] of Object.entries(config.users)) { if (!entry || entry.storage === "keyring") continue; @@ -384,7 +506,13 @@ function hasScopedUserKeys(config: Pick): boolea return Object.keys(config.users).some((userKey) => userKey.includes("|")); } -function toLatestForDisk(config: PfConfig | PfConfigV1): PfConfigV3 { +function hasUserEmailMetadata(config: Pick): boolean { + return Object.values(config.users).some( + (user) => user != null && "email" in user && user.email !== undefined, + ); +} + +function toLatestForDisk(config: PfConfig | PfConfigV2 | PfConfigV1): PfConfigV3 { const latestInput = config.version === 1 ? migrateV1ToV2(config) : config; return { ...latestInput, @@ -399,98 +527,31 @@ function toLatestForDisk(config: PfConfig | PfConfigV1): PfConfigV3 { * backward compatibility, so an older SDK can still read the file. Configs * containing a keyring user are kept in V2 or later because the keyring storage * variant is not representable in V1. Configs containing profile-level Platform - * settings or platform-scoped user tokens are written in the latest - * min-SDK-gated format because older SDKs would silently drop or misread those - * settings. - * Set TAILOR_USE_KEYRING to write the current in-memory format unconditionally. + * settings, platform-scoped user tokens, canonical user IDs, or email metadata + * are written in the latest min-SDK-gated format because older SDKs would + * silently drop or misread those settings. * * The config file may contain access/refresh tokens when the OS keyring is * unavailable, so it is written via {@link writeSecretFile} so other users * on the host cannot read it. * @param config - Platform configuration to write */ -export function writePlatformConfig(config: PfConfig | PfConfigV1) { +export function writePlatformConfig(config: PfConfig | PfConfigV2 | PfConfigV1) { const configPath = platformConfigPath(); const hasKeyringUser = config.version !== 1 && Object.values(config.users).some((u) => u?.storage === "keyring"); const diskConfig = - hasProfilePlatformSettings(config) || hasScopedUserKeys(config) + config.version === LATEST_CONFIG_VERSION || + hasProfilePlatformSettings(config) || + hasScopedUserKeys(config) || + hasUserEmailMetadata(config) ? toLatestForDisk(config) - : config.version !== 1 && !process.env.TAILOR_USE_KEYRING && !hasKeyringUser + : config.version === V2_CONFIG_VERSION && !hasKeyringUser ? toV1ForDisk(config) : config; writeSecretFile(configPath, stringifyYAML(diskConfig)); } -const tcContextConfigSchema = z.object({ - username: z.string().optional(), - controlplaneaccesstoken: z.string().optional(), - controlplanerefreshtoken: z.string().optional(), - controlplanetokenexpiresat: z.string().optional(), - workspaceid: z.string().optional(), -}); - -const tcConfigSchema = z - .object({ - global: z - .object({ - context: z.string().optional(), - }) - .optional(), - }) - .catchall(tcContextConfigSchema.optional()); - -type TcConfig = z.output; -type TcContextConfig = z.output; - -function readTailorctlConfig(): TcConfig | undefined { - const configPath = path.join(os.homedir(), ".tailorctl", "config"); - if (!fs.existsSync(configPath)) { - return; - } - const rawConfig = parseTOML(fs.readFileSync(configPath, "utf-8")); - return tcConfigSchema.parse(rawConfig); -} - -function fromTailorctlConfig(config: TcConfig): PfConfigV1 { - const users: PfConfigV1["users"] = {}; - const profiles: PfConfigV1["profiles"] = {}; - let currentUser: PfConfigV1["current_user"] = null; - - const currentContext = config.global?.context || "default"; - for (const [key, val] of Object.entries(config)) { - if (key === "global") { - continue; - } - const context = val as TcContextConfig; - if ( - !context.username || - !context.controlplaneaccesstoken || - !context.controlplanerefreshtoken || - !context.controlplanetokenexpiresat || - !context.workspaceid - ) { - continue; - } - if (key === currentContext) { - currentUser = context.username; - } - profiles[key] = { - user: context.username, - workspace_id: context.workspaceid, - }; - const user = users[context.username]; - if (!user || new Date(user.token_expires_at) < new Date(context.controlplanetokenexpiresat)) { - users[context.username] = { - access_token: context.controlplaneaccesstoken, - refresh_token: context.controlplanerefreshtoken, - token_expires_at: context.controlplanetokenexpiresat, - }; - } - } - return { version: 1, users, profiles, current_user: currentUser }; -} - function validateUUID(value: string, source: string): string { const result = z.uuid().safeParse(value); if (!result.success) { @@ -602,7 +663,7 @@ export async function loadMachineUserName( code: "PROFILE_MACHINE_USER_OVERRIDE_DENIED", message: `Profile "${profile}" denies overriding the machine user.`, details, - suggestion: `Omit the machine user option, unset TAILOR_PLATFORM_MACHINE_USER_NAME, or run 'tailor-sdk profile update ${profile} --machine-user-override allow'.`, + suggestion: `Omit the machine user option, unset TAILOR_PLATFORM_MACHINE_USER_NAME, or run 'tailor profile update ${profile} --machine-user-override allow'.`, }); } return entry.machine_user; @@ -640,11 +701,11 @@ export async function loadAccessToken(opts?: LoadAccessTokenOptions) { if (!user) { throw new Error(ml` Tailor Platform token not found. - Please specify token via TAILOR_PLATFORM_TOKEN environment variable or login using 'tailor-sdk login' command. + Please specify token via TAILOR_PLATFORM_TOKEN environment variable or login using 'tailor login' command. `); } const fromProfile = profileEntry ? platformConfigFromProfile(profileEntry) : undefined; - return await fetchLatestToken(pfConfig, user, fromProfile); + return (await fetchLatestToken(pfConfig, user, fromProfile)).accessToken; } /** @@ -697,7 +758,7 @@ export async function loadConsoleBaseUrl(opts?: LoadConsoleBaseUrlOptions): Prom * @returns Access token and optional refresh token */ export async function resolveTokens( - userEntry: PfUserV2, + userEntry: PfUser, user: string, label = user, ): Promise<{ accessToken: string; refreshToken?: string }> { @@ -706,7 +767,7 @@ export async function resolveTokens( if (!tokens) { throw new Error(ml` Credentials not found in OS keyring for "${label}". - Please run 'tailor-sdk login' and try again. + Please run 'tailor login' and try again. `); } return tokens; @@ -718,41 +779,41 @@ export async function resolveTokens( }; } -interface UserTokenData { - accessToken: string; - refreshToken?: string; -} - /** - * Save tokens for a user, writing to keyring or config as appropriate. + * Save tokens for a user, writing to keyring by default when available. * @param config - Platform config * @param user - User identifier * @param tokens - Token data to save - * @param tokens.accessToken - Access token to persist - * @param tokens.refreshToken - Refresh token to persist when available + * @param tokens.accessToken - Access token to save + * @param tokens.refreshToken - Optional refresh token to save * @param expiresAt - Token expiration date - * @param platformConfig - Optional platform connection settings + * @param opts - Optional platform and user metadata */ export async function saveUserTokens( config: PfConfig, user: string, - tokens: UserTokenData, + tokens: UserTokens, expiresAt: string, - platformConfig?: PlatformClientConfig, + opts: { platformConfig?: PlatformClientConfig; email?: string } = {}, ): Promise { - const userKey = platformUserKey(user, platformConfig); - if (process.env.TAILOR_USE_KEYRING && (await isKeyringAvailable())) { - await saveKeyringTokens(userKey, tokens); + const userKey = platformUserKey(user, opts.platformConfig); + const email = opts.email ?? config.users[userKey]?.email; + if (await trySaveTokensInKeyring(userKey, tokens)) { config.users[userKey] = { token_expires_at: expiresAt, storage: "keyring", + ...(email ? { email } : {}), }; } else { + if (config.users[userKey]?.storage === "keyring") { + await deleteKeyringTokens(userKey); + } config.users[userKey] = { access_token: tokens.accessToken, refresh_token: tokens.refreshToken, token_expires_at: expiresAt, storage: "file", + ...(email ? { email } : {}), }; } } @@ -792,7 +853,7 @@ export async function loadStoredUserTokens( opts?: UserEntryLookupOptions, ): Promise< | { - userEntry: PfUserV2; + userEntry: PfUser; accessToken: string; refreshToken?: string; } @@ -804,37 +865,83 @@ export async function loadStoredUserTokens( return { userEntry, ...tokens }; } +function updateUserReferences(config: PfConfig, fromUser: string, toUser: string) { + if (fromUser === toUser) return; + if (config.current_user === fromUser) { + config.current_user = toUser; + } + for (const profile of Object.values(config.profiles)) { + if (profile?.user === fromUser) { + profile.user = toUser; + } + } +} + +/** + * Remove a legacy alias after a canonical user ID has been written. + * @param config - Platform config + * @param legacyUser - Previous user key + * @param canonicalUser - Canonical user key + * @param platformConfig - Platform settings for scoped token storage + */ +export async function removeLegacyUserAlias( + config: PfConfig, + legacyUser: string, + canonicalUser: string, + platformConfig?: PlatformClientConfig, +): Promise { + if (legacyUser === canonicalUser) return; + updateUserReferences(config, legacyUser, canonicalUser); + const canonicalKey = platformUserKey(canonicalUser, platformConfig); + const legacyKeys = new Set([legacyUser, platformUserKey(legacyUser, platformConfig)]); + for (const legacyKey of legacyKeys) { + if (legacyKey === canonicalKey) continue; + const entry = config.users[legacyKey]; + if (entry?.storage === "keyring") { + await deleteKeyringTokens(legacyKey); + } + delete config.users[legacyKey]; + } +} + +function shouldResolveSubjectOnRefresh(user: string, userEntry: PfUser): boolean { + return Boolean(userEntry.email || inferEmailFromUserId(user)); +} + /** * Fetch the latest access token, refreshing if necessary. * @param config - Platform config - * @param user - User name + * @param user - User identifier * @param platformConfig - Optional platform connection settings - * @returns Latest access token + * @returns Latest access token and the canonical user ID it is stored under + * (the resolved subject when a legacy email key was migrated during refresh, + * otherwise the matching config user) */ export async function fetchLatestToken( config: PfConfig, user: string, platformConfig?: PlatformClientConfig, -): Promise { - const { userKey, userEntry } = findUserEntry(config, user, platformConfig); +): Promise<{ accessToken: string; user: string }> { + const { userKey: storedUser, userEntry } = findUserEntry(config, user, platformConfig); if (!userEntry) { throw new Error(ml` User "${user}" not found. - Please verify your user name and login using 'tailor-sdk login' command. + Please verify your user name and login using 'tailor login' command. `); } - const tokens = await resolveTokens(userEntry, userKey, user); + const storedConfigUser = userFromPlatformUserKey(storedUser, platformConfig); + const tokens = await resolveTokens(userEntry, storedUser, user); if (new Date(userEntry.token_expires_at) > new Date()) { rememberPlatformConfigForToken(tokens.accessToken, platformConfig); - return tokens.accessToken; + return { accessToken: tokens.accessToken, user: storedConfigUser }; } if (!tokens.refreshToken) { throw new Error(ml` Token expired. - Please run 'tailor-sdk login' and try again. + Please run 'tailor login' and try again. `); } @@ -849,33 +956,56 @@ export async function fetchLatestToken( } catch { throw new Error(ml` Failed to refresh token. Your session may have expired. - Please run 'tailor-sdk login' and try again. + Please run 'tailor login' and try again. `); } const newExpiresAt = new Date( assertDefined(resp.expiresAt, "token refresh response missing expiresAt"), ).toISOString(); + + let resolvedUser = storedConfigUser; + const previousEmail = + userEntry.email ?? inferEmailFromUserId(user) ?? inferEmailFromUserId(storedConfigUser); + let email = previousEmail; + if ( + shouldResolveSubjectOnRefresh(user, userEntry) || + shouldResolveSubjectOnRefresh(storedConfigUser, userEntry) + ) { + try { + const userInfo = await fetchUserInfo(resp.accessToken, platformConfig); + resolvedUser = userInfo.sub; + email = userInfo.email; + } catch (error) { + logger.debug(`Failed to resolve refreshed token user info: ${String(error)}`); + } + } + await saveUserTokens( config, - user, + resolvedUser, { accessToken: resp.accessToken, refreshToken: resp.refreshToken ?? undefined, }, newExpiresAt, - platformConfig, + { platformConfig, email }, ); - const refreshedUserKey = platformUserKey(user, platformConfig); - if (userKey !== refreshedUserKey) { - if (userEntry.storage === "keyring") { - await deleteKeyringTokens(userKey); + await removeLegacyUserAlias(config, user, resolvedUser, platformConfig); + const canonicalKey = platformUserKey(resolvedUser, platformConfig); + if (storedUser !== canonicalKey) { + const entry = config.users[storedUser]; + if (entry?.storage === "keyring") { + await deleteKeyringTokens(storedUser); } - delete config.users[userKey]; + delete config.users[storedUser]; + } + if (previousEmail && email && previousEmail !== email) { + logger.info(`Updated local user email from "${previousEmail}" to "${email}".`); } writePlatformConfig(config); rememberPlatformConfigForToken(resp.accessToken, platformConfig); - return resp.accessToken; + return { accessToken: resp.accessToken, user: resolvedUser }; } const DEFAULT_CONFIG_FILENAME = "tailor.config.ts"; @@ -891,8 +1021,8 @@ export function loadConfigPath(configPath?: string): string | undefined { if (configPath) { return configPath; } - if (process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH) { - return process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH; + if (process.env.TAILOR_CONFIG_PATH) { + return process.env.TAILOR_CONFIG_PATH; } // Search for config file in current directory and parent directories diff --git a/packages/sdk/src/cli/shared/dist-dir.ts b/packages/sdk/src/cli/shared/dist-dir.ts index 7a8c4cef8..aa8cd376d 100644 --- a/packages/sdk/src/cli/shared/dist-dir.ts +++ b/packages/sdk/src/cli/shared/dist-dir.ts @@ -1,11 +1,11 @@ let distPath: string | null = null; export const getDistDir = (): string => { - const configured = process.env.TAILOR_SDK_OUTPUT_DIR; + const configured = process.env.TAILOR_BUILD_OUTPUT_DIR; if (configured && configured !== distPath) { distPath = configured; } else if (distPath === null) { - distPath = configured || ".tailor-sdk"; + distPath = configured || ".tailor"; } return distPath; }; diff --git a/packages/sdk/src/cli/shared/errors.ts b/packages/sdk/src/cli/shared/errors.ts index ba100cb47..6ee431fd5 100644 --- a/packages/sdk/src/cli/shared/errors.ts +++ b/packages/sdk/src/cli/shared/errors.ts @@ -85,7 +85,7 @@ function formatError(error: CLIError): string { if (error.command) { parts.push( - `\n ${chalk.gray("Help:")} Run \`tailor-sdk ${error.command} --help\` for usage information.`, + `\n ${chalk.gray("Help:")} Run \`tailor ${error.command} --help\` for usage information.`, ); } diff --git a/packages/sdk/src/cli/shared/function-script-download.test.ts b/packages/sdk/src/cli/shared/function-script-download.test.ts index a0eb514b1..872c9d7e2 100644 --- a/packages/sdk/src/cli/shared/function-script-download.test.ts +++ b/packages/sdk/src/cli/shared/function-script-download.test.ts @@ -1,4 +1,3 @@ -import { timestampFromDate } from "@bufbuild/protobuf/wkt"; import { FunctionExecution_Type } from "@tailor-platform/tailor-proto/function_resource_pb"; import { describe, test, expect, vi } from "vitest"; import { downloadFunctionScript, scriptNameToRegistryName } from "./function-script-download"; @@ -21,53 +20,32 @@ function makeStreamingClient(responses: DownloadResponse[]): OperatorClient { } as unknown as OperatorClient; } -function chunk(text: string): DownloadResponse { - return { payload: { case: "chunk", value: new TextEncoder().encode(text) } }; -} - -async function download( - client: OperatorClient, - overrides: { name?: string; contentHash?: string } = {}, -) { - return downloadFunctionScript({ - client, - workspaceId: "ws-1", - name: overrides.name ?? "my-fn", - contentHash: overrides.contentHash, - }); -} - describe("downloadFunctionScript", () => { - test("concatenates chunks into a UTF-8 string and returns registry updatedAt", async () => { - const updatedAt = new Date("2024-03-01T00:00:00Z"); + test("concatenates chunks into a UTF-8 string", async () => { const client = makeStreamingClient([ - { - payload: { - case: "metadata", - value: { function: { updatedAt: timestampFromDate(updatedAt) } }, - }, - }, - chunk("hello, "), - chunk("world"), + { payload: { case: "chunk", value: new TextEncoder().encode("hello, ") } }, + { payload: { case: "chunk", value: new TextEncoder().encode("world") } }, ]); - const result = await download(client); - - expect(result).toEqual({ code: "hello, world", registryUpdatedAt: updatedAt }); - }); - - test("returns registryUpdatedAt as null when metadata omits the timestamp", async () => { - const client = makeStreamingClient([{ payload: { case: "metadata", value: {} } }, chunk("x")]); - - const result = await download(client); + const result = await downloadFunctionScript({ + client, + workspaceId: "ws-1", + name: "my-fn", + }); - expect(result).toEqual({ code: "x", registryUpdatedAt: null }); + expect(result).toEqual({ code: "hello, world" }); }); test("returns null when no chunks are received", async () => { const client = makeStreamingClient([{ payload: { case: "metadata", value: {} } }]); - expect(await download(client)).toBeNull(); + const result = await downloadFunctionScript({ + client, + workspaceId: "ws-1", + name: "my-fn", + }); + + expect(result).toBeNull(); }); test("returns null when the streaming RPC throws", async () => { @@ -77,7 +55,13 @@ describe("downloadFunctionScript", () => { }), } as unknown as OperatorClient; - expect(await download(client, { name: "missing-fn" })).toBeNull(); + const result = await downloadFunctionScript({ + client, + workspaceId: "ws-1", + name: "missing-fn", + }); + + expect(result).toBeNull(); }); test("forwards translated registry name to the RPC request", async () => { @@ -85,11 +69,18 @@ describe("downloadFunctionScript", () => { // scriptNameToRegistryName before calling. This test verifies the // raw `name` field is forwarded as-is so the translation contract // is enforced at the call site. - const client = makeStreamingClient([chunk("x")]); + const fn = vi.fn(async function* () { + yield { payload: { case: "chunk" as const, value: new TextEncoder().encode("x") } }; + }); + const client = { downloadFunctionRegistryScript: fn } as unknown as OperatorClient; - await download(client, { name: "resolver--ns--myFn" }); + await downloadFunctionScript({ + client, + workspaceId: "ws-1", + name: "resolver--ns--myFn", + }); - expect(client.downloadFunctionRegistryScript).toHaveBeenCalledWith({ + expect(fn).toHaveBeenCalledWith({ workspaceId: "ws-1", name: "resolver--ns--myFn", contentHash: undefined, @@ -97,11 +88,19 @@ describe("downloadFunctionScript", () => { }); test("forwards contentHash to the RPC request", async () => { - const client = makeStreamingClient([chunk("x")]); + const fn = vi.fn(async function* () { + yield { payload: { case: "chunk" as const, value: new TextEncoder().encode("x") } }; + }); + const client = { downloadFunctionRegistryScript: fn } as unknown as OperatorClient; - await download(client, { contentHash: "abc123" }); + await downloadFunctionScript({ + client, + workspaceId: "ws-1", + name: "my-fn", + contentHash: "abc123", + }); - expect(client.downloadFunctionRegistryScript).toHaveBeenCalledWith({ + expect(fn).toHaveBeenCalledWith({ workspaceId: "ws-1", name: "my-fn", contentHash: "abc123", @@ -110,33 +109,10 @@ describe("downloadFunctionScript", () => { }); describe("scriptNameToRegistryName", () => { - test.each([ - [ - "translates resolver scriptName to registry name", - "my-resolver.throwError.body.js", - FunctionExecution_Type.STANDARD, - "resolver--my-resolver--throwError", - ], - [ - "translates executor scriptName to registry name", - "user-changed.operation.js", - FunctionExecution_Type.STANDARD, - "executor--user-changed", - ], - [ - "translates auth hook scriptName to registry name", - "my-auth.before-login.hook.js", - FunctionExecution_Type.STANDARD, - "auth-hook--my-auth--before-login", - ], - [ - "translates workflow job scriptName (no dots) under JOB type", - "validate-order", - FunctionExecution_Type.JOB, - "workflow--validate-order", - ], - ])("%s", (_description, scriptName, type, expected) => { - expect(scriptNameToRegistryName(scriptName, type)).toBe(expected); + test("translates resolver scriptName to registry name", () => { + expect( + scriptNameToRegistryName("my-resolver.throwError.body.js", FunctionExecution_Type.STANDARD), + ).toBe("resolver--my-resolver--throwError"); }); test("preserves dots in resolver name (non-greedy namespace)", () => { @@ -146,6 +122,24 @@ describe("scriptNameToRegistryName", () => { ); }); + test("translates executor scriptName to registry name", () => { + expect( + scriptNameToRegistryName("user-changed.operation.js", FunctionExecution_Type.STANDARD), + ).toBe("executor--user-changed"); + }); + + test("translates auth hook scriptName to registry name", () => { + expect( + scriptNameToRegistryName("my-auth.before-login.hook.js", FunctionExecution_Type.STANDARD), + ).toBe("auth-hook--my-auth--before-login"); + }); + + test("translates workflow job scriptName (no dots) under JOB type", () => { + expect(scriptNameToRegistryName("validate-order", FunctionExecution_Type.JOB)).toBe( + "workflow--validate-order", + ); + }); + test("translates workflow job scriptName that contains dots under JOB type", () => { // WorkflowJobSchema.name is an unconstrained string, so job names // may legitimately contain dots. The JOB type discriminator must @@ -158,13 +152,23 @@ describe("scriptNameToRegistryName", () => { ).toBe("workflow--looks-like-a-resolver.body.js"); }); - test.each([ - ["ad-hoc test-run scripts", "test-run--throwError.js"], - ["seed scripts", "seed-tailordb.ts"], - ["query scripts", "query-gql.js"], - ["query scripts", "query-sql-tailordb.js"], - ])("returns null for %s under STANDARD type: %j", (_kind, scriptName) => { - expect(scriptNameToRegistryName(scriptName, FunctionExecution_Type.STANDARD)).toBeNull(); + test("returns null for ad-hoc test-run scripts under STANDARD type", () => { + expect( + scriptNameToRegistryName("test-run--throwError.js", FunctionExecution_Type.STANDARD), + ).toBeNull(); + }); + + test("returns null for seed scripts under STANDARD type", () => { + expect( + scriptNameToRegistryName("seed-tailordb.ts", FunctionExecution_Type.STANDARD), + ).toBeNull(); + }); + + test("returns null for query scripts under STANDARD type", () => { + expect(scriptNameToRegistryName("query-gql.js", FunctionExecution_Type.STANDARD)).toBeNull(); + expect( + scriptNameToRegistryName("query-sql-tailordb.js", FunctionExecution_Type.STANDARD), + ).toBeNull(); }); test("falls back to extension parsing for UNSPECIFIED type (legacy servers)", () => { diff --git a/packages/sdk/src/cli/shared/function-script-download.ts b/packages/sdk/src/cli/shared/function-script-download.ts index 6eefea27c..f789eb81b 100644 --- a/packages/sdk/src/cli/shared/function-script-download.ts +++ b/packages/sdk/src/cli/shared/function-script-download.ts @@ -5,7 +5,6 @@ * concatenates content chunks into a single UTF-8 string. */ -import { timestampDate } from "@bufbuild/protobuf/wkt"; import { FunctionExecution_Type } from "@tailor-platform/tailor-proto/function_resource_pb"; import { logger } from "#/cli/shared/logger"; import type { OperatorClient } from "#/cli/shared/client"; @@ -96,25 +95,17 @@ export interface DownloadFunctionScriptOptions { export interface DownloadedFunctionScript { /** Bundled script content as a UTF-8 string */ code: string; - /** - * Server-side last-update timestamp of the registry entry, or null - * when the metadata message omitted it. Callers comparing against an - * execution timestamp use this to detect redeploys that happened - * after the execution ran. - */ - registryUpdatedAt: Date | null; } /** * Download a deployed function script. * - * Returns the bundled script content together with the registry - * entry's `updatedAt` timestamp. Returns null when the download fails - * (script removed, network error, etc.) or when no content chunks are - * received; errors are swallowed so callers can fall back to a - * non-sourcemap display. + * Returns the bundled script content. Returns null when the download + * fails (script removed, network error, etc.) or when no content + * chunks are received; errors are swallowed so callers can fall back + * to a non-sourcemap display. * @param options - Download options - * @returns Script content plus metadata, or null on failure / empty response + * @returns Script content, or null on failure / empty response */ export async function downloadFunctionScript( options: DownloadFunctionScriptOptions, @@ -122,24 +113,17 @@ export async function downloadFunctionScript( const { client, workspaceId, name, contentHash } = options; try { const chunks: Uint8Array[] = []; - let registryUpdatedAt: Date | null = null; for await (const response of client.downloadFunctionRegistryScript({ workspaceId, name, contentHash, })) { - if (response.payload.case === "metadata") { - const updatedAt = response.payload.value.function?.updatedAt; - if (updatedAt) registryUpdatedAt = timestampDate(updatedAt); - } else if (response.payload.case === "chunk") { + if (response.payload.case === "chunk") { chunks.push(response.payload.value); } } if (chunks.length === 0) return null; - return { - code: Buffer.concat(chunks).toString("utf-8"), - registryUpdatedAt, - }; + return { code: Buffer.concat(chunks).toString("utf-8") }; } catch (error) { logger.debug(`Failed to download function script "${options.name}": ${error}`); return null; diff --git a/packages/sdk/src/cli/shared/inline-sourcemap.ts b/packages/sdk/src/cli/shared/inline-sourcemap.ts index c11785f46..4725d75ee 100644 --- a/packages/sdk/src/cli/shared/inline-sourcemap.ts +++ b/packages/sdk/src/cli/shared/inline-sourcemap.ts @@ -5,14 +5,14 @@ import { parseBoolean } from "./parse-boolean"; * * Resolution order: * 1. Config value (`inlineSourcemap` in defineConfig) — if explicitly set - * 2. Environment variable `TAILOR_ENABLE_INLINE_SOURCEMAP` — if explicitly set + * 2. Environment variable `TAILOR_INLINE_SOURCEMAP` — if explicitly set * 3. Default: `true` * @param configValue - The `inlineSourcemap` value from AppConfig * @returns Whether inline sourcemaps should be enabled */ export function resolveInlineSourcemap(configValue?: boolean): boolean { if (configValue !== undefined) return configValue; - const envValue = parseBoolean(process.env.TAILOR_ENABLE_INLINE_SOURCEMAP); + const envValue = parseBoolean(process.env.TAILOR_INLINE_SOURCEMAP); if (envValue !== undefined) return envValue; return true; } diff --git a/packages/sdk/src/cli/shared/platform-bundle-plugin.test.ts b/packages/sdk/src/cli/shared/platform-bundle-plugin.test.ts index e12ad5030..5f8f158d3 100644 --- a/packages/sdk/src/cli/shared/platform-bundle-plugin.test.ts +++ b/packages/sdk/src/cli/shared/platform-bundle-plugin.test.ts @@ -10,7 +10,7 @@ const run = (code: string): string | null => { describe("platformBundleDefinePlugin", () => { test("folds the gate member-expression to true", () => { - expect(run("if (!process.env.TAILOR_PLATFORM_BUNDLE) register();")).toBe( + expect(run("if (!process.env.__TAILOR_PLATFORM_BUNDLE) register();")).toBe( "if (!true) register();", ); }); @@ -20,9 +20,9 @@ describe("platformBundleDefinePlugin", () => { }); test("does not rewrite a longer key or a different owner", () => { - const longerKey = "read(process.env.TAILOR_PLATFORM_BUNDLE_MODE);"; + const longerKey = "read(process.env.__TAILOR_PLATFORM_BUNDLE_MODE);"; expect(run(longerKey)).toBe(longerKey); - const otherOwner = "read(self.process.env.TAILOR_PLATFORM_BUNDLE);"; + const otherOwner = "read(self.process.env.__TAILOR_PLATFORM_BUNDLE);"; expect(run(otherOwner)).toBe(otherOwner); }); }); diff --git a/packages/sdk/src/cli/shared/platform-bundle-plugin.ts b/packages/sdk/src/cli/shared/platform-bundle-plugin.ts index bd784ceea..b87233443 100644 --- a/packages/sdk/src/cli/shared/platform-bundle-plugin.ts +++ b/packages/sdk/src/cli/shared/platform-bundle-plugin.ts @@ -1,9 +1,9 @@ import type * as rolldown from "rolldown"; -// Match the exact `process.env.TAILOR_PLATFORM_BUNDLE` member-expression: the +// Match the exact `process.env.__TAILOR_PLATFORM_BUNDLE` member-expression: the // leading lookbehind rejects a longer owner (`foo.process.env…`) or identifier, // the trailing `\b` rejects a longer key (`…_BUNDLE_MODE`). -const GATE = /(? ({ + loadAccessToken: vi.fn(), + loadWorkspaceId: vi.fn(), + loadConfigPath: vi.fn(), + loadPlatformClientConfig: vi.fn(), + readPlatformConfig: vi.fn(), +})); + +vi.mock("./context", () => contextMocks); + +const isWindows = process.platform === "win32"; +const CLI = "tailor-sdk"; + +/** + * Create an executable fake plugin file. + * @param dir - Directory to create the file in + * @param name - File name + * @returns Absolute path to the created file + */ +function writeExecutable(dir: string, name: string): string { + fs.mkdirSync(dir, { recursive: true }); + const full = path.join(dir, name); + fs.writeFileSync(full, "#!/bin/sh\necho hi\n"); + if (!isWindows) fs.chmodSync(full, 0o755); + return full; +} + +describe("resolvePlugin / listPlugins", () => { + let tempDir: string; + let originalCwd: string; + let originalPath: string | undefined; + + beforeEach(() => { + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tailor-plugin-"))); + originalCwd = process.cwd(); + originalPath = process.env.PATH; + }); + + afterEach(() => { + process.chdir(originalCwd); + process.env.PATH = originalPath; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("resolves a plugin from the nearest node_modules/.bin", () => { + const project = path.join(tempDir, "project"); + const binDir = path.join(project, "node_modules", ".bin"); + const exe = writeExecutable(binDir, `${CLI}-hello`); + process.chdir(project); + process.env.PATH = ""; + + const resolved = resolvePlugin("hello", CLI); + expect(resolved).toEqual({ name: "hello", path: exe, source: "node_modules" }); + }); + + test("resolves a plugin from PATH when not in node_modules", () => { + const project = path.join(tempDir, "project"); + fs.mkdirSync(project, { recursive: true }); + const pathDir = path.join(tempDir, "bin"); + const exe = writeExecutable(pathDir, `${CLI}-frompath`); + process.chdir(project); + process.env.PATH = pathDir; + + const resolved = resolvePlugin("frompath", CLI); + expect(resolved).toEqual({ name: "frompath", path: exe, source: "path" }); + }); + + test("prefers node_modules/.bin over PATH on collision", () => { + const project = path.join(tempDir, "project"); + const binDir = path.join(project, "node_modules", ".bin"); + const nmExe = writeExecutable(binDir, `${CLI}-dup`); + const pathDir = path.join(tempDir, "bin"); + writeExecutable(pathDir, `${CLI}-dup`); + process.chdir(project); + process.env.PATH = pathDir; + + const resolved = resolvePlugin("dup", CLI); + expect(resolved?.source).toBe("node_modules"); + expect(resolved?.path).toBe(nmExe); + }); + + test("returns null when no matching plugin exists", () => { + const project = path.join(tempDir, "project"); + fs.mkdirSync(project, { recursive: true }); + process.chdir(project); + process.env.PATH = ""; + + expect(resolvePlugin("missing", CLI)).toBeNull(); + }); + + test("rejects names containing path separators or NUL", () => { + // A traversal-shaped name must never resolve, even if such a file exists. + const binDir = path.join(tempDir, "project", "node_modules", ".bin"); + writeExecutable(binDir, `${CLI}-evil`); + process.chdir(path.join(tempDir, "project")); + process.env.PATH = ""; + + expect(resolvePlugin("../evil", CLI)).toBeNull(); + expect(resolvePlugin("a/b", CLI)).toBeNull(); + expect(resolvePlugin("a\\b", CLI)).toBeNull(); + expect(resolvePlugin("a\0b", CLI)).toBeNull(); + }); + + test("lists discovered plugins, deduping by name with node_modules precedence", () => { + const project = path.join(tempDir, "project"); + const binDir = path.join(project, "node_modules", ".bin"); + writeExecutable(binDir, `${CLI}-alpha`); + writeExecutable(binDir, `${CLI}-dup`); + const pathDir = path.join(tempDir, "bin"); + writeExecutable(pathDir, `${CLI}-beta`); + writeExecutable(pathDir, `${CLI}-dup`); + // A non-plugin executable must be ignored. + writeExecutable(pathDir, "unrelated-tool"); + process.chdir(project); + process.env.PATH = pathDir; + + const plugins = listPlugins(CLI); + const byName = Object.fromEntries(plugins.map((p) => [p.name, p])); + + expect(Object.keys(byName).toSorted()).toEqual(["alpha", "beta", "dup"]); + expect(byName.alpha?.source).toBe("node_modules"); + expect(byName.beta?.source).toBe("path"); + expect(byName.dup?.source).toBe("node_modules"); + }); +}); + +/** + * Write an executable node plugin that dumps its env and forwarded argv to + * `outFile` as JSON, then exits with `exitCode`. + * @param dir - Directory to create the plugin in + * @param name - Plugin file name + * @param outFile - Path the plugin writes its captured context to + * @param exitCode - Exit code the plugin terminates with + * @returns Absolute path to the created plugin + */ +function writeCapturePlugin(dir: string, name: string, outFile: string, exitCode = 0): string { + fs.mkdirSync(dir, { recursive: true }); + const jsBody = `const fs = require("node:fs"); +fs.writeFileSync(${JSON.stringify(outFile)}, JSON.stringify({ env: process.env, argv: process.argv.slice(2) })); +process.exit(${exitCode}); +`; + if (isWindows) { + // Windows can't execute a shebang script directly, so ship a `.cmd` wrapper + // that runs Node (by absolute path, so PATH need not contain node) on a + // companion `.js` file. This exercises the `.cmd` dispatch branch. + const jsPath = path.join(dir, `${name}.js`); + fs.writeFileSync(jsPath, jsBody); + const cmdPath = path.join(dir, `${name}.cmd`); + fs.writeFileSync(cmdPath, `@"${process.execPath}" "${jsPath}" %*\r\n`); + return cmdPath; + } + const full = path.join(dir, name); + fs.writeFileSync(full, `#!${process.execPath}\n${jsBody}`); + fs.chmodSync(full, 0o755); + return full; +} + +// Context vars that may be present in the ambient environment; cleared so the +// dispatched child only sees what buildPluginEnv injects. +const CONTEXT_ENV_KEYS = [ + "TAILOR_PLATFORM_TOKEN", + "TAILOR_PLATFORM_WORKSPACE_ID", + "TAILOR_PLATFORM_USER", + "TAILOR_CONFIG_PATH", + "TAILOR_PLATFORM_PROFILE", +] as const; + +describe("dispatchPlugin", () => { + let tempDir: string; + let originalCwd: string; + let originalPath: string | undefined; + let originalContextEnv: Record; + let outFile: string; + + beforeEach(() => { + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tailor-dispatch-"))); + originalCwd = process.cwd(); + originalPath = process.env.PATH; + originalContextEnv = Object.fromEntries(CONTEXT_ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of CONTEXT_ENV_KEYS) delete process.env[k]; + outFile = path.join(tempDir, "capture.json"); + + contextMocks.loadAccessToken.mockResolvedValue("tok-123"); + contextMocks.loadWorkspaceId.mockResolvedValue("ws-456"); + contextMocks.loadConfigPath.mockReturnValue("/proj/tailor.config.ts"); + contextMocks.loadPlatformClientConfig.mockResolvedValue(undefined); + contextMocks.readPlatformConfig.mockResolvedValue({ + users: { u1: { email: "me@example.com" } }, + profiles: {}, + current_user: "u1", + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + process.env.PATH = originalPath; + for (const [k, v] of Object.entries(originalContextEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + vi.clearAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + /** + * Read the JSON context captured by the dispatched plugin. + * @returns The plugin's env and forwarded argv + */ + function readCapture(): { env: Record; argv: string[] } { + return JSON.parse(fs.readFileSync(outFile, "utf-8")); + } + + test("forwards args and injects the resolved platform context", async () => { + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-hello`, outFile); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ + name: "hello", + args: ["world", "--loud"], + cliName: CLI, + }); + + expect(code).toBe(0); + const { env, argv } = readCapture(); + expect(argv).toEqual(["world", "--loud"]); + expect(env.TAILOR_PLATFORM_URL).toBe(getPlatformBaseUrl()); + expect(env.TAILOR_PLATFORM_OAUTH2_CLIENT_ID).toBe(getOAuth2ClientId()); + expect(env.TAILOR_PLATFORM_TOKEN).toBe("tok-123"); + expect(env.TAILOR_PLATFORM_WORKSPACE_ID).toBe("ws-456"); + expect(env.TAILOR_PLATFORM_USER).toBe("me@example.com"); + expect(env.TAILOR_CONFIG_PATH).toBe("/proj/tailor.config.ts"); + expect(env.TAILOR_BIN).toBeTruthy(); + expect(env.TAILOR_VERSION).toBeTruthy(); + }); + + test("injects the active profile's platform URL and OAuth client", async () => { + contextMocks.loadPlatformClientConfig.mockResolvedValue({ + platformUrl: "https://api.staging.example.com", + oauth2ClientId: "cpoc_staging", + }); + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-hello`, outFile); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ + name: "hello", + args: [], + cliName: CLI, + profile: "staging", + }); + + expect(code).toBe(0); + const { env } = readCapture(); + expect(env.TAILOR_PLATFORM_URL).toBe("https://api.staging.example.com"); + expect(env.TAILOR_PLATFORM_OAUTH2_CLIENT_ID).toBe("cpoc_staging"); + }); + + test("resolves injected context from an explicit --profile in forwarded args", async () => { + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-hello`, outFile); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ + name: "hello", + args: ["deploy", "--profile", "staging"], + cliName: CLI, + profile: "prod", + }); + + expect(code).toBe(0); + expect(contextMocks.loadAccessToken).toHaveBeenCalledWith({ profile: "staging" }); + expect(contextMocks.loadWorkspaceId).toHaveBeenCalledWith({ profile: "staging" }); + }); + + test("skips platform context injection when the forwarded args carry an env-file flag", async () => { + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-hello`, outFile); + process.chdir(project); + process.env.PATH = ""; + // Node itself also parses --env-file after the script path + // (https://github.com/nodejs/node/issues/54232), so the file must exist for + // the capture plugin process to start. + fs.writeFileSync(path.join(project, ".env.staging"), ""); + + const code = await dispatchPlugin({ + name: "hello", + args: ["deploy", "--env-file", ".env.staging"], + cliName: CLI, + }); + + expect(code).toBe(0); + const { env } = readCapture(); + expect(env.TAILOR_PLATFORM_TOKEN).toBeUndefined(); + expect(env.TAILOR_PLATFORM_WORKSPACE_ID).toBeUndefined(); + expect(env.TAILOR_PLATFORM_USER).toBeUndefined(); + expect(env.TAILOR_PLATFORM_URL).toBeUndefined(); + expect(env.TAILOR_PLATFORM_OAUTH2_CLIENT_ID).toBeUndefined(); + expect(env.TAILOR_CONFIG_PATH).toBe("/proj/tailor.config.ts"); + }); + + test("skips platform context even when the --env-file-if-exists file is missing", async () => { + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-hello`, outFile); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ + name: "hello", + args: ["deploy", "--env-file-if-exists", ".env.missing"], + cliName: CLI, + }); + + // The flag check is presence-only: with the file absent, neither the + // injected context nor the plugin's env-file loader supplies platform vars. + expect(code).toBe(0); + const { env } = readCapture(); + expect(env.TAILOR_PLATFORM_TOKEN).toBeUndefined(); + expect(env.TAILOR_PLATFORM_WORKSPACE_ID).toBeUndefined(); + expect(env.TAILOR_PLATFORM_URL).toBeUndefined(); + }); + + test("omits best-effort context when it cannot be resolved but still dispatches", async () => { + contextMocks.loadAccessToken.mockRejectedValue(new Error("not logged in")); + contextMocks.loadWorkspaceId.mockRejectedValue(new Error("no workspace")); + contextMocks.readPlatformConfig.mockResolvedValue({ + users: {}, + profiles: {}, + current_user: null, + }); + + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-hello`, outFile); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ name: "hello", args: [], cliName: CLI }); + + expect(code).toBe(0); + const { env } = readCapture(); + expect(env.TAILOR_PLATFORM_TOKEN).toBeUndefined(); + expect(env.TAILOR_PLATFORM_WORKSPACE_ID).toBeUndefined(); + expect(env.TAILOR_PLATFORM_USER).toBeUndefined(); + expect(env.TAILOR_PLATFORM_URL).toBe(getPlatformBaseUrl()); + }); + + test("builds the plugin slug from the command path for nested dispatch", async () => { + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-tailordb-erd`, outFile); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ + commandPath: ["tailordb"], + name: "erd", + args: ["export"], + cliName: CLI, + }); + + expect(code).toBe(0); + expect(readCapture().argv).toEqual(["export"]); + }); + + test("propagates a non-zero exit code", async () => { + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-boom`, outFile, 3); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ name: "boom", args: [], cliName: CLI }); + expect(code).toBe(3); + }); + + test("returns undefined when no matching plugin exists", async () => { + const project = path.join(tempDir, "project"); + fs.mkdirSync(project, { recursive: true }); + process.chdir(project); + process.env.PATH = ""; + + expect(await dispatchPlugin({ name: "missing", args: [], cliName: CLI })).toBeUndefined(); + }); +}); + +describe("dispatchPluginWithInstallHint", () => { + let tempDir: string; + let originalCwd: string; + let originalPath: string | undefined; + + beforeEach(() => { + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tailor-hint-"))); + originalCwd = process.cwd(); + originalPath = process.env.PATH; + const project = path.join(tempDir, "project"); + fs.mkdirSync(project, { recursive: true }); + process.chdir(project); + process.env.PATH = ""; + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.chdir(originalCwd); + process.env.PATH = originalPath; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("prints an install hint when a known plugin package is not installed", async () => { + const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {}); + const infoSpy = vi.spyOn(logger, "info").mockImplementation(() => {}); + + const code = await dispatchPluginWithInstallHint({ + commandPath: ["tailordb"], + name: "erd", + args: ["export"], + cliName: CLI, + }); + + expect(code).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + `"${CLI} tailordb erd" is provided by the @tailor-platform/sdk-plugin-tailordb-erd CLI plugin, which is not installed.`, + ); + expect(infoSpy).toHaveBeenCalledWith( + "Install it with: npm install -D @tailor-platform/sdk-plugin-tailordb-erd", + ); + }); + + 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(() => {}); + + const code = await dispatchPluginWithInstallHint({ + commandPath: [], + name: "missing", + args: [], + cliName: CLI, + }); + + expect(code).toBeUndefined(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(infoSpy).not.toHaveBeenCalled(); + }); +}); + +describe("explicitProfileFromArgs", () => { + test.each([ + [["deploy", "--profile", "staging"], "staging"], + [["deploy", "-p", "staging"], "staging"], + [["deploy", "--profile=staging"], "staging"], + [["deploy", "-p=staging"], "staging"], + [["--profile", "a", "deploy", "--profile", "b"], "b"], + [["deploy"], undefined], + [["deploy", "--profile"], undefined], + [["deploy", "--profile", "--json"], undefined], + [["deploy", "--", "--profile", "staging"], undefined], + ])("extracts profile from %j", (args, expected) => { + expect(explicitProfileFromArgs(args)).toBe(expected); + }); +}); + +describe("hasEnvFileFlag", () => { + test.each([ + [["deploy", "--env-file", ".env"], true], + [["deploy", "-e", ".env"], true], + [["deploy", "--env-file=.env"], true], + [["deploy", "--env-file-if-exists", ".env"], true], + [["deploy"], false], + [["deploy", "--", "--env-file", ".env"], false], + ])("detects env-file flag in %j", (args, expected) => { + expect(hasEnvFileFlag(args)).toBe(expected); + }); +}); diff --git a/packages/sdk/src/cli/shared/plugin.ts b/packages/sdk/src/cli/shared/plugin.ts new file mode 100644 index 000000000..1f6672d20 --- /dev/null +++ b/packages/sdk/src/cli/shared/plugin.ts @@ -0,0 +1,506 @@ +import { spawn } from "node:child_process"; +import { accessSync, constants, readdirSync } from "node:fs"; +import * as os from "node:os"; +import * as path from "pathe"; +import { getOAuth2ClientId, getPlatformBaseUrl, type PlatformClientConfig } from "./client"; +import { + loadAccessToken, + loadConfigPath, + loadPlatformClientConfig, + loadWorkspaceId, + readPlatformConfig, +} from "./context"; +import { logger } from "./logger"; +import { readPackageJson } from "./package-json"; + +/** + * npm packages known to provide a plugin slug, keyed by the slug the + * dispatcher resolves (command path joined with `-`). Used to suggest an + * install command when the plugin executable is not found. + */ +const KNOWN_PLUGIN_PACKAGES: Record = { + "tailordb-erd": "@tailor-platform/sdk-plugin-tailordb-erd", +}; + +/** + * A plugin discovered on the filesystem. `tailor ` dispatches to the + * external `tailor-` executable. + */ +export interface DiscoveredPlugin { + /** Plugin name (the part after the `-` prefix). */ + name: string; + /** Absolute path to the executable. */ + path: string; + /** Where the executable was found. */ + source: "node_modules" | "path"; +} + +/** PATH entry separator (`;` on Windows, `:` elsewhere). */ +const pathDelimiter = process.platform === "win32" ? ";" : ":"; + +/** + * Yield `start` and each ancestor directory up to the filesystem root. + * @param start - Directory to start from + * @yields Each ancestor directory, starting with `start` + */ +function* ancestorDirs(start: string): Generator { + let dir = path.resolve(start); + // Walk up until `dirname` stops changing (the filesystem root), yielding the + // root on the final iteration. + let parent = path.dirname(dir); + while (parent !== dir) { + yield dir; + dir = parent; + parent = path.dirname(dir); + } + yield dir; +} + +/** Fallback executable extensions when `PATHEXT` is unset (Windows). */ +const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD;.PS1"; + +/** + * Lowercased executable extensions from `PATHEXT` (Windows). + * @returns Recognized executable extensions (e.g. `.exe`, `.cmd`) + */ +function windowsExecutableExts(): string[] { + return (process.env.PATHEXT ?? DEFAULT_PATHEXT) + .split(";") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean); +} + +/** + * Check whether a path exists and is runnable: X_OK on POSIX; on Windows, the + * file must exist and either be extension-less (shebang script) or carry a + * `PATHEXT` extension, so plain data files are not mistaken for plugins. + * @param filePath - Path to check + * @returns Whether the path is an executable file + */ +function isExecutable(filePath: string): boolean { + try { + accessSync(filePath, process.platform === "win32" ? constants.F_OK : constants.X_OK); + } catch { + return false; + } + if (process.platform === "win32") { + const ext = path.extname(filePath).toLowerCase(); + return ext === "" || windowsExecutableExts().includes(ext); + } + return true; +} + +/** + * Candidate file names for a plugin binary, accounting for Windows extensions. + * @param binName - Base binary name (e.g. `tailor-foo`) + * @returns Ordered candidate file names + */ +function binaryCandidates(binName: string): string[] { + if (process.platform !== "win32") { + return [binName]; + } + // Bare name first (shebang scripts), then PATHEXT variants. + return [binName, ...windowsExecutableExts().map((ext) => `${binName}${ext}`)]; +} + +/** + * Directories on the user's PATH. + * @returns PATH entries + */ +function pathDirs(): string[] { + return (process.env.PATH ?? "").split(pathDelimiter).filter(Boolean); +} + +/** + * Nearest `node_modules/.bin` directories, walking up from the current working + * directory. Project-local bins take precedence over those higher in the tree. + * @returns Ordered `node_modules/.bin` directories + */ +function nodeModulesBinDirs(): string[] { + const dirs: string[] = []; + for (const dir of ancestorDirs(process.cwd())) { + dirs.push(path.join(dir, "node_modules", ".bin")); + } + return dirs; +} + +/** + * Find the first existing executable for the given base name across a set of + * directories. + * @param dirs - Directories to search + * @param binName - Base binary name + * @returns Absolute path, or undefined when not found + */ +function findExecutableIn(dirs: string[], binName: string): string | undefined { + const candidates = binaryCandidates(binName); + for (const dir of dirs) { + for (const candidate of candidates) { + const full = path.join(dir, candidate); + if (isExecutable(full)) return full; + } + } + return undefined; +} + +/** + * Resolve a plugin executable by name. + * Search order: project-local `node_modules/.bin` (nearest first), then PATH. + * @param name - Plugin name (without the `-` prefix) + * @param cliName - The host CLI name (e.g. `tailor`) + * @returns The discovered plugin, or null when not found + */ +export function resolvePlugin(name: string, cliName: string): DiscoveredPlugin | null { + // Reject names that could escape the `-` prefix via path traversal + // (e.g. `../evil`) or embed a NUL, before joining them into a filesystem path. + if (name.includes("/") || name.includes("\\") || name.includes("\0")) { + return null; + } + const binName = `${cliName}-${name}`; + + const fromNodeModules = findExecutableIn(nodeModulesBinDirs(), binName); + if (fromNodeModules) { + return { name, path: fromNodeModules, source: "node_modules" }; + } + + const fromPath = findExecutableIn(pathDirs(), binName); + if (fromPath) { + return { name, path: fromPath, source: "path" }; + } + + return null; +} + +/** + * Strip a known binary extension from a file name (Windows). + * @param fileName - File name + * @returns File name without a recognized executable extension + */ +function stripBinExt(fileName: string): string { + if (process.platform !== "win32") return fileName; + const ext = path.extname(fileName); + return ext ? fileName.slice(0, -ext.length) : fileName; +} + +/** + * Discover all plugins reachable from `node_modules/.bin` and PATH. + * Earlier entries win on name collisions (project-local over PATH). + * @param cliName - The host CLI name (e.g. `tailor`) + * @returns Discovered plugins, deduped by name + */ +export function listPlugins(cliName: string): DiscoveredPlugin[] { + const prefix = `${cliName}-`; + const seen = new Map(); + + const scan = (dirs: string[], source: DiscoveredPlugin["source"]) => { + for (const dir of dirs) { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + const base = stripBinExt(entry); + if (!base.startsWith(prefix) || base.length === prefix.length) continue; + const name = base.slice(prefix.length); + if (seen.has(name)) continue; + const full = path.join(dir, entry); + if (!isExecutable(full)) continue; + seen.set(name, { name, path: full, source }); + } + } + }; + + scan(nodeModulesBinDirs(), "node_modules"); + scan(pathDirs(), "path"); + + return [...seen.values()]; +} + +/** + * Options for {@link buildPluginEnv}. + */ +interface PluginContextOptions { + /** Active profile used to resolve the workspace, user, and token. */ + profile?: string | undefined; + /** Skip platform context (URL, client ID, workspace, user, token) injection. */ + skipPlatformContext?: boolean; +} + +/** + * Resolve the active user identifier, preferring the stored email over the + * internal user key. + * @param profile - Active profile name, if any + * @returns The resolved user (email when known), or undefined when not logged in + */ +async function resolveActiveUser(profile?: string): Promise { + const config = await readPlatformConfig(); + const user = profile ? config.profiles[profile]?.user : config.current_user; + if (!user) return undefined; + return config.users[user]?.email ?? user; +} + +/** + * Build the environment variables injected into a dispatched plugin. + * Workspace, user, and token are resolved best-effort: whichever the current + * context can provide is injected, and the rest are omitted so auth-free + * plugins still run. + * @param options - Plugin context options + * @returns Environment variables to merge into the child process env + */ +async function buildPluginEnv(options: PluginContextOptions = {}): Promise> { + const { profile } = options; + const env: Record = {}; + + const binPath = process.argv[1]; + if (binPath) { + env.TAILOR_BIN = binPath; + } + + try { + const { version } = await readPackageJson(); + if (version) env.TAILOR_VERSION = version; + } catch { + // Version is informational; skip when unavailable. + } + + const configPath = loadConfigPath(); + if (configPath) { + env.TAILOR_CONFIG_PATH = configPath; + } + + // An explicit --env-file supplies the plugin's platform context itself. + // Injected values would shadow it (the plugin's env-file loader never + // overwrites pre-existing keys), so skip context injection entirely. + if (options.skipPlatformContext) { + return env; + } + + // Resolve the active profile's platform settings so the injected URL and + // OAuth client match the profile the token and workspace belong to. + let platformConfig: PlatformClientConfig | undefined; + try { + platformConfig = await loadPlatformClientConfig({ profile, allowMissingProfile: true }); + } catch { + // Fall back to env/default platform settings when the profile is unreadable. + } + env.TAILOR_PLATFORM_URL = getPlatformBaseUrl(platformConfig); + env.TAILOR_PLATFORM_OAUTH2_CLIENT_ID = getOAuth2ClientId(platformConfig); + + try { + env.TAILOR_PLATFORM_WORKSPACE_ID = await loadWorkspaceId({ profile }); + } catch { + // No workspace resolvable; plugins that need it surface their own error. + } + + try { + const user = await resolveActiveUser(profile); + if (user) env.TAILOR_PLATFORM_USER = user; + } catch { + // User context is best-effort; skip when the config is unreadable. + } + + try { + const token = await loadAccessToken({ profile }); + if (token) env.TAILOR_PLATFORM_TOKEN = token; + } catch { + // Not logged in (or no token): dispatch without a token so auth-free + // plugins still work. Auth-requiring plugins surface their own error + // (e.g. via `tailor auth token`). + } + + return env; +} + +/** + * Map an exit signal to a conventional exit code (128 + signal number). + * @param signal - Signal name + * @returns Exit code + */ +function signalToExitCode(signal: NodeJS.Signals): number { + const num = os.constants.signals[signal]; + return typeof num === "number" ? 128 + num : 1; +} + +/** + * Build the command + args for spawning a plugin, handling Windows shebang + * scripts via `sh` (following the `gh` extension dispatcher). + * @param pluginPath - Resolved plugin executable path + * @param args - Args to forward to the plugin + * @returns Spawn command, args, and whether a shell is required + */ +function buildSpawnTarget( + pluginPath: string, + args: readonly string[], +): { command: string; args: string[]; shell: boolean } { + if (process.platform !== "win32") { + return { command: pluginPath, args: [...args], shell: false }; + } + + const ext = path.extname(pluginPath).toLowerCase(); + if (ext === ".exe" || ext === ".com") { + return { command: pluginPath, args: [...args], shell: false }; + } + if (ext === ".cmd" || ext === ".bat") { + // .cmd/.bat must run through the shell on Windows. + return { command: pluginPath, args: [...args], shell: true }; + } + if (ext === ".ps1") { + // PowerShell scripts are not directly executable via spawn. + const pwsh = + findExecutableIn(pathDirs(), "pwsh") ?? + findExecutableIn(pathDirs(), "powershell") ?? + "powershell"; + return { + command: pwsh, + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", pluginPath, ...args], + shell: false, + }; + } + + // Extension-less shebang script: dispatch through `sh` (Git for Windows). + const sh = findExecutableIn(pathDirs(), "sh") ?? "sh"; + return { command: sh, args: ["-c", 'command "$@"', "--", pluginPath, ...args], shell: false }; +} + +/** + * Check whether args forwarded to a plugin carry an explicit env-file flag. + * Scanning stops at a `--` terminator. The check is presence-only: + * `--env-file-if-exists` counts even when the named file does not exist, so + * no platform context is injected in that case either. + * @param args - Args forwarded to the plugin + * @returns Whether an `--env-file`/`-e`/`--env-file-if-exists` flag is present + */ +export function hasEnvFileFlag(args: readonly string[]): boolean { + for (const token of args) { + if (token === "--") return false; + if (token === "--env-file" || token === "-e" || token === "--env-file-if-exists") { + return true; + } + if ( + token.startsWith("--env-file=") || + token.startsWith("-e=") || + token.startsWith("--env-file-if-exists=") + ) { + return true; + } + } + return false; +} + +/** + * Extract an explicit `--profile`/`-p` value from args forwarded to a plugin, + * so the injected platform context matches the profile the plugin will use. + * Scanning stops at a `--` terminator; the last occurrence wins. + * @param args - Args forwarded to the plugin + * @returns The explicit profile value, or undefined when not present + */ +export function explicitProfileFromArgs(args: readonly string[]): string | undefined { + let profile: string | undefined; + for (let i = 0; i < args.length; i++) { + const token = args[i]; + if (token === undefined || token === "--") break; + if (token === "--profile" || token === "-p") { + const next = args[i + 1]; + if (next !== undefined && !next.startsWith("-")) { + profile = next; + i++; + } + continue; + } + if (token.startsWith("--profile=")) { + profile = token.slice("--profile=".length); + } else if (token.startsWith("-p=")) { + profile = token.slice("-p=".length); + } + } + return profile || undefined; +} + +/** + * Parameters for {@link dispatchPlugin}. + */ +interface DispatchPluginParams { + /** Plugin name (the part after the `-` prefix). */ + name: string; + /** Args to forward to the plugin. */ + args: readonly string[]; + /** The host CLI name (e.g. `tailor`). */ + cliName: string; + /** Parent command path preceding the unknown subcommand, if any. */ + commandPath?: readonly string[] | undefined; + /** Active profile name, if any. */ + profile?: string | undefined; +} + +/** + * Build the plugin slug from the known command path plus the unknown name, + * so `tailor tailordb erd` resolves `tailor-tailordb-erd`. + * @param commandPath - Parent command path preceding the unknown subcommand + * @param name - Unknown subcommand name + * @returns The plugin slug + */ +function pluginSlug(commandPath: readonly string[] | undefined, name: string): string { + return [...(commandPath ?? []), name].join("-"); +} + +/** + * Resolve and execute a plugin, forwarding stdio and propagating its exit code. + * @param params - Dispatch parameters + * @returns The plugin's exit code, or undefined when no matching plugin exists + */ +export async function dispatchPlugin(params: DispatchPluginParams): Promise { + const slug = pluginSlug(params.commandPath, params.name); + const plugin = resolvePlugin(slug, params.cliName); + if (!plugin) { + return undefined; + } + + const profile = explicitProfileFromArgs(params.args) ?? params.profile; + const env = { + ...process.env, + ...(await buildPluginEnv({ profile, skipPlatformContext: hasEnvFileFlag(params.args) })), + }; + const { command, args, shell } = buildSpawnTarget(plugin.path, params.args); + + return await new Promise((resolve) => { + const child = spawn(command, args, { stdio: "inherit", env, shell }); + child.on("error", (error) => { + logger.error(`Failed to run plugin "${params.cliName}-${slug}": ${error.message}`); + resolve(1); + }); + child.on("close", (code, signal) => { + if (signal) { + resolve(signalToExitCode(signal)); + } else { + resolve(code ?? 0); + } + }); + }); +} + +/** + * Dispatch a plugin, printing an install hint when the subcommand maps to a + * known plugin package that is not installed. + * @param params - Dispatch parameters + * @returns The plugin's exit code (1 after an install hint), or undefined when + * the subcommand matches no plugin and no known package + */ +export async function dispatchPluginWithInstallHint( + params: DispatchPluginParams, +): Promise { + const exitCode = await dispatchPlugin(params); + if (exitCode !== undefined) { + return exitCode; + } + const knownPackage = KNOWN_PLUGIN_PACKAGES[pluginSlug(params.commandPath, params.name)]; + if (!knownPackage) { + return undefined; + } + const fullCommand = [params.cliName, ...(params.commandPath ?? []), params.name].join(" "); + logger.error( + `"${fullCommand}" is provided by the ${knownPackage} CLI plugin, which is not installed.`, + ); + logger.info(`Install it with: npm install -D ${knownPackage}`); + return 1; +} diff --git a/packages/sdk/src/cli/shared/readonly-guard.test.ts b/packages/sdk/src/cli/shared/readonly-guard.test.ts index 307776357..968cf79cf 100644 --- a/packages/sdk/src/cli/shared/readonly-guard.test.ts +++ b/packages/sdk/src/cli/shared/readonly-guard.test.ts @@ -37,6 +37,9 @@ const READ_OR_LOCAL_COMMAND_PATHS = new Set([ // (including Create*/Update*/Delete*) and must guard. "api/inspect.ts", "api/list.ts", + // Auth token retrieval (may refresh via the OAuth server and persist tokens + // locally, but mutates no workspace/platform state) + "auth/token.ts", // Auth connections (read-only) "authconnection/index.ts", "authconnection/list.ts", @@ -80,6 +83,9 @@ const READ_OR_LOCAL_COMMAND_PATHS = new Set([ "organization/folder/index.ts", "organization/folder/get.ts", "organization/folder/list.ts", + // Plugin discovery (local filesystem listing only) + "plugin/index.ts", + "plugin/list.ts", // Profile management (local config only, never platform state) "profile/index.ts", "profile/create.ts", @@ -93,9 +99,6 @@ const READ_OR_LOCAL_COMMAND_PATHS = new Set([ "secret/vault/list.ts", // Setup (local file generation) "setup/index.ts", - // Skills (local file install) - "skills/index.ts", - "skills/install.ts", // Static website (read-only) "staticwebsite/index.ts", "staticwebsite/get.ts", @@ -105,10 +108,6 @@ const READ_OR_LOCAL_COMMAND_PATHS = new Set([ "staticwebsite/domain/list.ts", // TailorDB (read-only / local ops) "tailordb/index.ts", - "tailordb/erd/index.ts", - "tailordb/erd/diff-command.ts", - "tailordb/erd/export.ts", - "tailordb/erd/serve.ts", "tailordb/migrate/index.ts", "tailordb/migrate/generate.ts", "tailordb/migrate/script.ts", diff --git a/packages/sdk/src/cli/shared/readonly-guard.ts b/packages/sdk/src/cli/shared/readonly-guard.ts index dfabff37e..1ba3242fe 100644 --- a/packages/sdk/src/cli/shared/readonly-guard.ts +++ b/packages/sdk/src/cli/shared/readonly-guard.ts @@ -38,6 +38,6 @@ export async function assertWritable(opts?: AssertWritableOptions): Promise ({ + registerHooks: vi.fn(), +})); + +vi.mock("node:module", () => nodeModuleMock); + +import { registerTsHook } from "./register-ts-hook"; + +describe("registerTsHook", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + test("skips hook registration on Bun (native TypeScript runtime)", async () => { + vi.stubGlobal("Bun", {}); + await registerTsHook(new URL("file:///ts-hook.mjs")); + expect(nodeModuleMock.registerHooks).not.toHaveBeenCalled(); + }); + + test("skips hook registration on Deno (native TypeScript runtime)", async () => { + vi.stubGlobal("Deno", {}); + await registerTsHook(new URL("file:///ts-hook.mjs")); + expect(nodeModuleMock.registerHooks).not.toHaveBeenCalled(); + }); + + test("calls module.registerHooks() with resolve/load on Node.js", async () => { + const tsHookUrl = new URL("../ts-hook.mjs", import.meta.url); + await registerTsHook(tsHookUrl); + expect(nodeModuleMock.registerHooks).toHaveBeenCalledWith({ + resolve: expect.any(Function), + load: expect.any(Function), + }); + }); +}); diff --git a/packages/sdk/src/cli/shared/register-ts-hook.ts b/packages/sdk/src/cli/shared/register-ts-hook.ts new file mode 100644 index 000000000..0c7bd636f --- /dev/null +++ b/packages/sdk/src/cli/shared/register-ts-hook.ts @@ -0,0 +1,11 @@ +import * as mod from "node:module"; +import { isNativeTypeScriptRuntime } from "./runtime"; + +export async function registerTsHook(tsHookUrl: URL): Promise { + if (isNativeTypeScriptRuntime()) return; + const { resolveSync, loadSync } = (await import(tsHookUrl.href)) as { + resolveSync: Parameters[0]["resolve"]; + loadSync: Parameters[0]["load"]; + }; + mod.registerHooks({ resolve: resolveSync, load: loadSync }); +} diff --git a/packages/sdk/src/cli/shared/register-tsconfig-paths-hook.test.ts b/packages/sdk/src/cli/shared/register-tsconfig-paths-hook.test.ts deleted file mode 100644 index 5ed9f76e4..000000000 --- a/packages/sdk/src/cli/shared/register-tsconfig-paths-hook.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; - -const nodeModuleMock = vi.hoisted(() => ({ - register: vi.fn(), -})); - -vi.mock("node:module", () => nodeModuleMock); - -import { registerTsconfigPathsHook } from "./register-tsconfig-paths-hook"; - -describe("registerTsconfigPathsHook", () => { - afterEach(() => { - vi.unstubAllGlobals(); - vi.clearAllMocks(); - nodeModuleMock.register = vi.fn(); - }); - - test("skips hook registration on Bun (native TypeScript runtime)", async () => { - vi.stubGlobal("Bun", {}); - await registerTsconfigPathsHook(new URL("file:///tsconfig-paths-hook.mjs")); - expect(nodeModuleMock.register).not.toHaveBeenCalled(); - }); - - test("skips hook registration on Deno (native TypeScript runtime)", async () => { - vi.stubGlobal("Deno", {}); - await registerTsconfigPathsHook(new URL("file:///tsconfig-paths-hook.mjs")); - expect(nodeModuleMock.register).not.toHaveBeenCalled(); - }); - - test("skips registration when module.register is unavailable on this Node.js version", async () => { - nodeModuleMock.register = undefined as unknown as typeof nodeModuleMock.register; - const hookUrl = new URL("../tsconfig-paths-hook.mjs", import.meta.url); - await expect(registerTsconfigPathsHook(hookUrl)).resolves.toBeUndefined(); - }); - - test("calls module.register() with the hook URL on Node.js", async () => { - const hookUrl = new URL("../tsconfig-paths-hook.mjs", import.meta.url); - await registerTsconfigPathsHook(hookUrl); - expect(nodeModuleMock.register).toHaveBeenCalledWith(hookUrl); - }); -}); diff --git a/packages/sdk/src/cli/shared/register-tsconfig-paths-hook.ts b/packages/sdk/src/cli/shared/register-tsconfig-paths-hook.ts deleted file mode 100644 index 132fde47c..000000000 --- a/packages/sdk/src/cli/shared/register-tsconfig-paths-hook.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as mod from "node:module"; -import { isNativeTypeScriptRuntime } from "./runtime"; - -/** - * Register the tsconfig `paths` alias resolve hook alongside tsx. - * - * tsx's own tsconfig-paths support is scoped to the tsconfig discovered from - * where tsx was registered, so a project-local alias in a dynamically - * imported user file (resolver, executor, workflow, TailorDB type) can fail - * to resolve when the importing file lives outside that directory. This - * hook activates only when tsx's own resolution throws (a fallback, not an - * override): it then re-derives `paths` from each importing file's own - * directory, without touching TypeScript transformation (left to tsx). - * - * Uses `module.register()` rather than `module.registerHooks()`: chaining - * another `register()` loader after tsx's composes correctly regardless of - * which of the two internal registration APIs tsx itself picks (it varies - * by Node.js version). - * @param hookUrl - URL of the tsconfig-paths-hook.mjs module. - */ -export async function registerTsconfigPathsHook(hookUrl: URL): Promise { - if (isNativeTypeScriptRuntime()) return; - if (typeof mod.register !== "function") return; - - mod.register(hookUrl); -} diff --git a/packages/sdk/src/cli/shared/register-typescript-runtime.test.ts b/packages/sdk/src/cli/shared/register-typescript-runtime.test.ts deleted file mode 100644 index ef09d5c1c..000000000 --- a/packages/sdk/src/cli/shared/register-typescript-runtime.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; - -const nodeModuleMock = vi.hoisted(() => ({ - register: vi.fn(), -})); -const tsxRegisterMock = vi.hoisted(() => vi.fn()); - -vi.mock("node:module", () => nodeModuleMock); -vi.mock("tsx/esm/api", () => ({ register: tsxRegisterMock })); - -import { registerTypeScriptRuntime } from "./register-typescript-runtime"; - -describe("registerTypeScriptRuntime", () => { - afterEach(() => { - vi.unstubAllGlobals(); - vi.clearAllMocks(); - nodeModuleMock.register = vi.fn(); - }); - - test("skips tsx registration on Bun (native TypeScript runtime)", async () => { - vi.stubGlobal("Bun", {}); - await registerTypeScriptRuntime(new URL("../tsconfig-paths-hook.mjs", import.meta.url)); - expect(tsxRegisterMock).not.toHaveBeenCalled(); - expect(nodeModuleMock.register).not.toHaveBeenCalled(); - }); - - test("skips tsx registration on Deno (native TypeScript runtime)", async () => { - vi.stubGlobal("Deno", {}); - await registerTypeScriptRuntime(new URL("../tsconfig-paths-hook.mjs", import.meta.url)); - expect(tsxRegisterMock).not.toHaveBeenCalled(); - expect(nodeModuleMock.register).not.toHaveBeenCalled(); - }); - - test("registers tsx and the tsconfig paths hook together on Node.js", async () => { - const hookUrl = new URL("../tsconfig-paths-hook.mjs", import.meta.url); - await registerTypeScriptRuntime(hookUrl); - expect(tsxRegisterMock).toHaveBeenCalledTimes(1); - expect(nodeModuleMock.register).toHaveBeenCalledWith(hookUrl); - }); -}); diff --git a/packages/sdk/src/cli/shared/register-typescript-runtime.ts b/packages/sdk/src/cli/shared/register-typescript-runtime.ts deleted file mode 100644 index db6e315d9..000000000 --- a/packages/sdk/src/cli/shared/register-typescript-runtime.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { registerTsconfigPathsHook } from "./register-tsconfig-paths-hook"; -import { isNativeTypeScriptRuntime } from "./runtime"; - -/** - * Register TypeScript loading for the CLI: tsx for transformation, plus the - * tsconfig `paths` alias resolve hook tsx's own cwd-scoped resolver misses. - * - * Shared by both the `tailor` binary entrypoint and the programmatic `./cli` - * API entrypoint so the two registration steps can't drift apart. - * @param hookUrl - URL of the tsconfig-paths-hook.mjs module, resolved by the - * caller via its own `import.meta.url`. Bundling can move this shared module - * to a different output location than tsconfig-paths-hook.mjs, so the URL - * can't be resolved relative to this file — it must come from an entrypoint - * whose bundled output path stays predictable. - */ -export async function registerTypeScriptRuntime(hookUrl: URL): Promise { - // Bun and Deno handle TypeScript natively, so registration is skipped. - // tsx's own register() picks `module.registerHooks` on Node ≥ 24.11.1 / 25.1 / 26 - // (avoiding the DEP0205 deprecation) and falls back to `module.register` on older runtimes. - if (!isNativeTypeScriptRuntime()) { - const { register } = await import("tsx/esm/api"); - register(); - } - await registerTsconfigPathsHook(hookUrl); -} diff --git a/packages/sdk/src/cli/shared/runtime-exprs.test.ts b/packages/sdk/src/cli/shared/runtime-exprs.test.ts index 6a8e0ce48..c127955d7 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.test.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.test.ts @@ -1,5 +1,33 @@ import { describe, test, expect } from "vitest"; -import { buildExecutorArgsExpr, buildResolverOperationHookExpr } from "./runtime-exprs"; +import { + INVOKER_EXPR, + buildExecutorArgsExpr, + buildResolverOperationHookExpr, +} from "./runtime-exprs"; + +const runInvokerExpr = (invoker: unknown): unknown => + Function( + "tailor", + `return ${INVOKER_EXPR};`, + )({ + context: { getInvoker: () => invoker }, + }); + +const runExecutorArgsExpr = (args: Record): Record => + Function("args", `return ${buildExecutorArgsExpr("schedule", {})};`)(args) as Record< + string, + unknown + >; + +const runResolverOperationHookExpr = ( + user: unknown, + context = { pipeline: {}, args: {} }, +): Record => + Function( + "context", + "user", + `return ${buildResolverOperationHookExpr({})}`, + )(context, user) as Record; describe("buildExecutorArgsExpr", () => { const env = { API_URL: "https://example.com", DEBUG: true }; @@ -7,48 +35,104 @@ describe("buildExecutorArgsExpr", () => { describe("event triggers (with actor)", () => { const eventTriggerKinds = ["schedule", "tailordb", "idpUser", "authAccessToken"] as const; - test.each(eventTriggerKinds)("%s includes appNamespace, actor transform, and env", (kind) => { - const expr = buildExecutorArgsExpr(kind, env); - expect(expr).toContain("...args"); - expect(expr).toContain("appNamespace: args.namespaceName"); - expect(expr).toContain("actor: args.actor"); - expect(expr).toContain("attributeMap"); - expect(expr).toContain("attributeList"); - expect(expr).toContain(`env: ${JSON.stringify(env)}`); - }); - - test.each(["tailordb", "idpUser", "authAccessToken"] as const)( - "%s trigger injects kind and rawKind from args.eventType", - (kind) => { + for (const kind of eventTriggerKinds) { + test(`${kind} includes appNamespace, actor transform, and env`, () => { + const expr = buildExecutorArgsExpr(kind, env); + expect(expr).toContain("...args"); + expect(expr).toContain("appNamespace: args.namespaceName"); + expect(expr).toContain("actor: (($raw)"); + expect(expr).toContain("})(args.actor)"); + expect(expr).toContain("userType"); + expect(expr).toContain("attributeMap"); + expect(expr).toContain("attributeList"); + expect(expr).toContain(`env: ${JSON.stringify(env)}`); + }); + } + + test("event triggers inject kind and rawKind from args.eventType", () => { + const eventKinds = ["tailordb", "idpUser", "authAccessToken"] as const; + for (const kind of eventKinds) { const expr = buildExecutorArgsExpr(kind, env); expect(expr).toContain('event: args.eventType?.split(".").pop()'); expect(expr).toContain("rawEvent: args.eventType"); - }, - ); + } + }); test("schedule trigger does not inject event", () => { const expr = buildExecutorArgsExpr("schedule", env); expect(expr).not.toContain("event:"); }); + + test("maps actor payloads to TailorPrincipal shape", () => { + expect( + runExecutorArgsExpr({ + namespaceName: "app", + actor: { + userType: "USER_TYPE_USER", + userId: "user-1", + workspaceId: "workspace-1", + attributeMap: { role: "admin" }, + attributes: ["role"], + }, + }).actor, + ).toEqual({ + id: "user-1", + type: "user", + workspaceId: "workspace-1", + attributes: { role: "admin" }, + attributeList: ["role"], + }); + }); + + test("maps absent actor payloads to null", () => { + const nilUuid = "00000000-0000-0000-0000-000000000000"; + const actors = [ + null, + undefined, + { userType: "USER_TYPE_UNSPECIFIED", userId: "user-1" }, + { userType: "USER_TYPE_USER", userId: nilUuid }, + { userType: "USER_TYPE_USER" }, + ]; + for (const actor of actors) { + expect(runExecutorArgsExpr({ namespaceName: "app", actor }).actor).toBeNull(); + } + }); }); describe("resolverExecuted trigger", () => { - test("includes success/result/error transformations, actor, appNamespace, and env", () => { + test("includes success/result/error transformations", () => { const expr = buildExecutorArgsExpr("resolverExecuted", env); expect(expr).toContain("success: !!args.succeeded"); expect(expr).toContain("result: args.succeeded?.result.resolver"); expect(expr).toContain("error: args.failed?.error"); - expect(expr).toContain("actor: args.actor"); + }); + + test("includes actor transform and appNamespace", () => { + const expr = buildExecutorArgsExpr("resolverExecuted", env); + expect(expr).toContain("actor: (($raw)"); + expect(expr).toContain("})(args.actor)"); expect(expr).toContain("appNamespace: args.namespaceName"); + }); + + test("includes env", () => { + const expr = buildExecutorArgsExpr("resolverExecuted", env); expect(expr).toContain(`env: ${JSON.stringify(env)}`); }); }); describe("incomingWebhook trigger", () => { - test("includes rawBody mapping, appNamespace, and env, but not actor transform", () => { + test("includes rawBody mapping", () => { const expr = buildExecutorArgsExpr("incomingWebhook", env); expect(expr).toContain("rawBody: args.raw_body"); + }); + + test("does NOT include actor transform", () => { + const expr = buildExecutorArgsExpr("incomingWebhook", env); expect(expr).not.toContain("actor:"); + }); + + test("includes appNamespace and env", () => { + const expr = buildExecutorArgsExpr("incomingWebhook", env); expect(expr).toContain("appNamespace: args.namespaceName"); expect(expr).toContain(`env: ${JSON.stringify(env)}`); }); @@ -80,12 +164,12 @@ describe("buildResolverOperationHookExpr", () => { expect(expr).toContain("input: context.args"); }); - test("includes user transformation via tailorUserMap", () => { + test("includes caller transformation via tailorPrincipalMap", () => { const expr = buildResolverOperationHookExpr(env); - expect(expr).toContain("user:"); - expect(expr).toContain("user.workspace_id"); - expect(expr).toContain("user.attribute_map"); - expect(expr).toContain("user.attributes"); + expect(expr).toContain("caller:"); + expect(expr).toContain("workspace_id"); + expect(expr).toContain("attribute_map"); + expect(expr).toContain("attributes"); }); test("includes env injection", () => { @@ -102,4 +186,59 @@ describe("buildResolverOperationHookExpr", () => { const expr = buildResolverOperationHookExpr({}); expect(expr).toContain("env: {}"); }); + + test("maps caller payloads to TailorPrincipal shape", () => { + expect( + runResolverOperationHookExpr({ + type: "USER_TYPE_MACHINE_USER", + id: "machine-1", + workspace_id: "workspace-1", + attribute_map: { team: "ops" }, + attributes: ["team"], + }).caller, + ).toEqual({ + id: "machine-1", + type: "machine_user", + workspaceId: "workspace-1", + attributes: { team: "ops" }, + attributeList: ["team"], + }); + }); + + test("maps absent caller payloads to null", () => { + const nilUuid = "00000000-0000-0000-0000-000000000000"; + const users = [ + null, + undefined, + { type: "USER_TYPE_UNSPECIFIED", id: "user-1" }, + { type: "USER_TYPE_USER", id: nilUuid }, + ]; + for (const user of users) { + expect(runResolverOperationHookExpr(user).caller).toBeNull(); + } + }); +}); + +describe("INVOKER_EXPR", () => { + test("maps invoker payloads to TailorPrincipal shape", () => { + expect( + runInvokerExpr({ + id: "user-1", + type: "user", + workspaceId: "workspace-1", + attributeMap: { role: "member" }, + attributes: ["role"], + }), + ).toEqual({ + id: "user-1", + type: "user", + workspaceId: "workspace-1", + attributes: { role: "member" }, + attributeList: ["role"], + }); + }); + + test("maps anonymous invokers to null", () => { + expect(runInvokerExpr(null)).toBeNull(); + }); }); diff --git a/packages/sdk/src/cli/shared/runtime-exprs.ts b/packages/sdk/src/cli/shared/runtime-exprs.ts index 5e5042563..b307f7edd 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.ts @@ -7,10 +7,13 @@ * - Bundle inline: interpolated into the generated entry module and * evaluated inside the bundled script at function entry. * - * The user field mapping (server → SDK) shared across services is defined in - * `@/parser/service/tailordb` as `tailorUserMap`. + * The principal field mapping (server → SDK) shared across services is built by + * `makePrincipalExpr` from `@/parser/service/tailordb`; `tailorPrincipalMap` + * (the `caller` mapping) is one of its outputs. `INVOKER_EXPR` and + * `ACTOR_TRANSFORM_EXPR` below come from the same factory so the three stay in + * sync. */ -import { tailorUserMap } from "#/parser/service/tailordb/index"; +import { makePrincipalExpr, tailorPrincipalMap } from "#/parser/service/tailordb/index"; import type { Trigger } from "#/types/executor.generated"; // --------------------------------------------------------------------------- @@ -21,15 +24,21 @@ import type { Trigger } from "#/types/executor.generated"; * `invoker` value expression, inlined into bundler entry wrappers. * * Calls `tailor.context.getInvoker()` at function entry and maps the server - * shape to TailorInvoker. Anonymous callers (`null`) pass through as `null`. + * shape to `TailorPrincipal | null`. The payload is already in SDK type shape, + * so no `USER_TYPE_*` normalization is needed; anonymous callers (`null`) pass + * through as `null`. */ -export const INVOKER_EXPR = `(($raw) => $raw ? ({ - id: $raw.id, - type: $raw.type, - workspaceId: $raw.workspaceId, - attributes: $raw.attributeMap, - attributeList: $raw.attributes, -}) : null)(tailor.context.getInvoker())`; +export const INVOKER_EXPR = makePrincipalExpr({ + source: "tailor.context.getInvoker()", + normalize: false, + fields: { + type: { raw: "$raw.type" }, + id: "$raw.id", + workspaceId: "$raw.workspaceId", + attributes: "$raw.attributeMap", + attributeList: "$raw.attributes", + }, +}); // --------------------------------------------------------------------------- // Executor @@ -38,15 +47,20 @@ export const INVOKER_EXPR = `(($raw) => $raw ? ({ /** * Actor field transformation expression. * - * Transforms the server's actor object to match the SDK's TailorActor type: - * server `attributeMap` → SDK `attributes` - * server `attributes` → SDK `attributeList` - * other fields → passed through - * null/undefined actor → null + * Transforms the server's actor object to match `TailorPrincipal | null`. */ -const ACTOR_TRANSFORM_EXPR = - `actor: args.actor ? (({ attributeMap, attributes: attrList, ...rest }) => ` + - `({ ...rest, attributes: attributeMap, attributeList: attrList }))(args.actor) : null`; +const ACTOR_TRANSFORM_EXPR = `actor: ${makePrincipalExpr({ + source: "args.actor", + normalize: true, + requireId: true, + fields: { + type: { raw: "$raw?.userType", fallback: "$raw?.type" }, + id: "$raw?.userId ?? $raw?.id", + workspaceId: "$raw.workspaceId", + attributes: "$raw.attributeMap ?? {}", + attributeList: "$raw.attributes ?? []", + }, +})}`; /** * Build the JavaScript expression that transforms server-format executor event @@ -92,7 +106,7 @@ export function buildExecutorArgsExpr( * Transforms server context to SDK resolver context: * context.args → input * context.pipeline → spread into result - * user (global var) → TailorUser (via tailorUserMap: workspace_id→workspaceId, attribute_map→attributes, attributes→attributeList) + * user (global var) → caller (`TailorPrincipal | null`) * env → injected as JSON * @param env - Application env record to embed in the expression * @returns A JavaScript expression string for the operationHook @@ -100,5 +114,5 @@ export function buildExecutorArgsExpr( export function buildResolverOperationHookExpr( env: Record, ): string { - return `({ ...context.pipeline, input: context.args, user: ${tailorUserMap}, env: ${JSON.stringify(env)} });`; + return `({ ...context.pipeline, input: context.args, caller: ${tailorPrincipalMap}, env: ${JSON.stringify(env)} });`; } diff --git a/packages/sdk/src/cli/shared/script-executor.test.ts b/packages/sdk/src/cli/shared/script-executor.test.ts index 8e9b3c7bd..76048d96e 100644 --- a/packages/sdk/src/cli/shared/script-executor.test.ts +++ b/packages/sdk/src/cli/shared/script-executor.test.ts @@ -1,6 +1,11 @@ import { FunctionExecution_Status } from "@tailor-platform/tailor-proto/function_resource_pb"; import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; -import { waitForExecution, executeScript, DEFAULT_POLL_INTERVAL } from "./script-executor"; +import { + waitForExecution, + executeScript, + DEFAULT_POLL_INTERVAL, + type ScriptExecutionOptions, +} from "./script-executor"; import type { OperatorClient } from "#/cli/shared/client"; import type { AuthInvoker } from "@tailor-platform/tailor-proto/auth_resource_pb"; @@ -165,7 +170,7 @@ describe("executeScript", () => { workspaceId: "workspace-1", name: "test-script.js", code: "export function main() { return { success: true }; }", - arg: '{"input":"value"}', + arg: { input: "value" }, invoker: mockAuthInvoker, }); @@ -197,6 +202,29 @@ describe("executeScript", () => { expect(client.testExecScript).toHaveBeenCalledWith(expect.objectContaining({ arg: "{}" })); }); + test("accepts options typed as the bare ScriptExecutionOptions", async () => { + const client = createMockClient({ + testExecScript: vi.fn().mockResolvedValue({ executionId: "exec-123" }), + getFunctionExecution: vi.fn().mockResolvedValue({ + execution: { status: FunctionExecution_Status.SUCCESS, logs: "", result: "" }, + }), + }); + + // Regression: a typed-out options object must stay assignable to + // executeScript's parameter (arg is Jsonifiable, usable on its own). + const options: ScriptExecutionOptions = { + client, + workspaceId: "workspace-1", + name: "test-script.js", + code: "code", + arg: { a: 1 }, + invoker: mockAuthInvoker, + }; + + const result = await executeScript(options); + expect(result.success).toBe(true); + }); + test("returns failure result when script fails", async () => { const client = createExecScriptMockClient( execution( diff --git a/packages/sdk/src/cli/shared/script-executor.ts b/packages/sdk/src/cli/shared/script-executor.ts index b15f0ff0f..db1c60aab 100644 --- a/packages/sdk/src/cli/shared/script-executor.ts +++ b/packages/sdk/src/cli/shared/script-executor.ts @@ -8,6 +8,7 @@ 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 { Jsonifiable } from "type-fest"; /** * Default polling interval for script execution status in milliseconds (1 second) @@ -17,7 +18,7 @@ export const DEFAULT_POLL_INTERVAL = 1000; /** * Options for script execution */ -export interface ScriptExecutionOptions { +export interface ScriptExecutionOptions { /** Operator client instance */ client: OperatorClient; /** Workspace ID */ @@ -26,8 +27,8 @@ export interface ScriptExecutionOptions { name: string; /** Bundled script code to execute */ code: string; - /** Optional JSON string argument to pass to the script */ - arg?: string; + /** Optional JSON-serializable argument to pass to the script */ + arg?: T; /** Auth invoker for script execution */ invoker: AuthInvoker; /** Polling interval in milliseconds (default: 1000ms) */ @@ -117,8 +118,8 @@ export async function waitForExecution( * @param {ScriptExecutionOptions} options - Execution options * @returns {Promise} Execution result */ -export async function executeScript( - options: ScriptExecutionOptions, +export async function executeScript( + options: ScriptExecutionOptions, ): Promise { const { client, workspaceId, name, code, arg, invoker, pollInterval } = options; @@ -127,7 +128,7 @@ export async function executeScript( workspaceId, name, code, - arg: arg ?? JSON.stringify({}), + arg: JSON.stringify(arg === undefined ? {} : arg), invoker, }); const executionId = response.executionId; diff --git a/packages/sdk/src/cli/shared/skills-installer.test.ts b/packages/sdk/src/cli/shared/skills-installer.test.ts deleted file mode 100644 index c5a45ebd0..000000000 --- a/packages/sdk/src/cli/shared/skills-installer.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { SKILL_NAME, buildSkillsAddArgs, runSkillsInstaller } from "./skills-installer"; - -const TEST_SOURCE = "/fake/sdk/skills"; - -interface MockChildProcess { - on(event: "close", listener: (code: number | null) => void): MockChildProcess; - on(event: "error", listener: (error: Error) => void): MockChildProcess; -} - -const createMockChildProcess = () => { - const listeners = { - close: [] as Array<(code: number | null) => void>, - error: [] as Array<(error: Error) => void>, - }; - - function on(event: "close", listener: (code: number | null) => void): MockChildProcess; - function on(event: "error", listener: (error: Error) => void): MockChildProcess; - function on( - event: "close" | "error", - listener: ((code: number | null) => void) | ((error: Error) => void), - ): MockChildProcess { - if (event === "close") { - listeners.close.push(listener as (code: number | null) => void); - } else { - listeners.error.push(listener as (error: Error) => void); - } - return process; - } - - const process: MockChildProcess = { on }; - - return { - process, - emitClose: (code: number | null) => listeners.close.forEach((listener) => listener(code)), - emitError: (error: Error) => listeners.error.forEach((listener) => listener(error)), - }; -}; - -describe("skills-installer", () => { - test("builds skills add arguments with the provided source and --copy", () => { - expect(buildSkillsAddArgs({ source: TEST_SOURCE })).toEqual([ - "skills", - "add", - TEST_SOURCE, - "--skill", - SKILL_NAME, - "--copy", - ]); - }); - - test("prefers TAILOR_SDK_SKILLS_SOURCE env var over the passed source", () => { - vi.stubEnv("TAILOR_SDK_SKILLS_SOURCE", "/override/skills"); - try { - expect(buildSkillsAddArgs({ source: TEST_SOURCE })[2]).toBe("/override/skills"); - } finally { - vi.unstubAllEnvs(); - } - }); - - test("appends --agent and --yes when provided", () => { - expect( - buildSkillsAddArgs({ source: TEST_SOURCE, agent: "codex", yes: true }).slice(-3), - ).toEqual(["--agent", "codex", "--yes"]); - }); - - test("runs npx with generated arguments and returns exit code", async () => { - const mock = createMockChildProcess(); - const spawnFn = vi.fn(() => mock.process); - - const promise = runSkillsInstaller({ - source: TEST_SOURCE, - agent: "codex", - spawnFn, - }); - - expect(spawnFn).toHaveBeenCalledWith( - expect.stringMatching(/^npx(\\.cmd)?$/), - ["skills", "add", TEST_SOURCE, "--skill", SKILL_NAME, "--copy", "--agent", "codex"], - { stdio: "inherit" }, - ); - - mock.emitClose(0); - await expect(promise).resolves.toBe(0); - }); - - test("returns 1 when child process exits without status code", async () => { - const mock = createMockChildProcess(); - const spawnFn = vi.fn(() => mock.process); - const promise = runSkillsInstaller({ source: TEST_SOURCE, spawnFn }); - - mock.emitClose(null); - await expect(promise).resolves.toBe(1); - }); - - test("rejects when npx execution fails", async () => { - const mock = createMockChildProcess(); - const spawnFn = vi.fn(() => mock.process); - const promise = runSkillsInstaller({ source: TEST_SOURCE, spawnFn }); - - mock.emitError(new Error("spawn failed")); - await expect(promise).rejects.toThrow("spawn failed"); - }); -}); diff --git a/packages/sdk/src/cli/shared/skills-installer.ts b/packages/sdk/src/cli/shared/skills-installer.ts deleted file mode 100644 index 3effafb91..000000000 --- a/packages/sdk/src/cli/shared/skills-installer.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { spawn } from "node:child_process"; - -export const SKILL_NAME = "tailor-sdk"; -const SKILLS_SOURCE_ENV_KEY = "TAILOR_SDK_SKILLS_SOURCE"; - -interface ChildProcessLike { - on(event: "close", listener: (code: number | null) => void): ChildProcessLike; - on(event: "error", listener: (error: Error) => void): ChildProcessLike; -} - -type SpawnLike = ( - command: string, - args: string[], - options: { stdio: "inherit" }, -) => ChildProcessLike; - -export interface RunSkillsInstallerOptions { - source: string; - agent?: string; - yes?: boolean; - spawnFn?: SpawnLike; -} - -interface BuildSkillsAddArgsOptions { - source: string; - agent?: string; - yes?: boolean; -} - -function resolveNpxCommand(platform: NodeJS.Platform = process.platform): string { - return platform === "win32" ? "npx.cmd" : "npx"; -} - -function resolveSkillsSource(source: string): string { - return process.env[SKILLS_SOURCE_ENV_KEY] ?? source; -} - -/** - * Build CLI arguments for `skills add` with the fixed tailor-sdk skill target. - * `--copy` is included so the installed skill survives `pnpm install` wiping `node_modules`. - * @param options - Options controlling the generated `skills add` arguments - * @param options.source - Skill source package or path passed to `skills add` - * @param options.agent - Target agent name passed through with `--agent` - * @param options.yes - Whether to add `--yes` for non-interactive installation - * @returns CLI arguments for `npx skills add` - */ -export function buildSkillsAddArgs(options: BuildSkillsAddArgsOptions): string[] { - const args = [ - "skills", - "add", - resolveSkillsSource(options.source), - "--skill", - SKILL_NAME, - "--copy", - ]; - if (options.agent) args.push("--agent", options.agent); - if (options.yes) args.push("--yes"); - return args; -} - -/** - * Run `npx skills add` to install the tailor-sdk skill. - * @param options - Runtime options for skill installation - * @returns Process exit code from the spawned `npx` command - */ -export async function runSkillsInstaller(options: RunSkillsInstallerOptions): Promise { - const args = buildSkillsAddArgs({ - source: options.source, - agent: options.agent, - yes: options.yes, - }); - const spawnFn = - options.spawnFn ?? - ((command: string, spawnArgs: string[], spawnOptions: { stdio: "inherit" }) => - spawn(command, spawnArgs, spawnOptions)); - - const childProcess = spawnFn(resolveNpxCommand(), args, { stdio: "inherit" }); - - return await new Promise((resolve, reject) => { - childProcess.on("error", (error) => reject(error)); - childProcess.on("close", (code) => resolve(code ?? 1)); - }); -} diff --git a/packages/sdk/src/cli/shared/skills-packaged.test.ts b/packages/sdk/src/cli/shared/skills-packaged.test.ts index 6321dd979..5547db08a 100644 --- a/packages/sdk/src/cli/shared/skills-packaged.test.ts +++ b/packages/sdk/src/cli/shared/skills-packaged.test.ts @@ -42,6 +42,9 @@ describe("packaged skills", () => { expect(frontmatter?.name).toBe(name); expect(typeof frontmatter?.description).toBe("string"); expect((frontmatter?.description as string).trim().length).toBeGreaterThan(0); + expect(frontmatter?.metadata).toMatchObject({ + "politty-cli": "@tailor-platform/sdk:tailor", + }); }); test("references existing SDK files for every node_modules/@tailor-platform/sdk path", () => { diff --git a/packages/sdk/src/cli/shared/stack-trace.test.ts b/packages/sdk/src/cli/shared/stack-trace.test.ts index ad363e86e..9db400461 100644 --- a/packages/sdk/src/cli/shared/stack-trace.test.ts +++ b/packages/sdk/src/cli/shared/stack-trace.test.ts @@ -478,14 +478,14 @@ describe("formatMappedError", () => { }); test("prefixes dotfile-rooted paths with ./ so they are not mistaken for relative-path markers", () => { - // Regression: paths like `.tailor-sdk/test-run/...` start with `.` but + // Regression: paths like `.tailor/test-run/...` start with `.` but // are not `../` escapes. The display must prefix them with `./` so // users can tell they are cwd-relative. const frames: MappedStackFrame[] = [ { original: { functionName: "main", file: "file:///bundle.js", line: 1, column: 1 }, mapped: { - source: ".tailor-sdk/test-run/test-run--add.entry.js", + source: ".tailor/test-run/test-run--add.entry.js", line: 16, column: 13, name: null, @@ -496,7 +496,7 @@ describe("formatMappedError", () => { const result = formatMappedError("Error: test", frames, null, process.cwd()); const plain = stripVTControlCharacters(result); - expect(plain).toContain("./.tailor-sdk/test-run/test-run--add.entry.js:16:13"); + expect(plain).toContain("./.tailor/test-run/test-run--add.entry.js:16:13"); }); }); diff --git a/packages/sdk/src/cli/shared/stack-trace.ts b/packages/sdk/src/cli/shared/stack-trace.ts index a35d0115c..2f05c7dad 100644 --- a/packages/sdk/src/cli/shared/stack-trace.ts +++ b/packages/sdk/src/cli/shared/stack-trace.ts @@ -295,7 +295,7 @@ export function formatMappedError( const rel = path.relative(process.cwd(), absolutePath); // Only paths escaping cwd (starting with `..`) are shown as-is; all // other relative paths get an explicit `./` prefix so dotfiles like - // `.tailor-sdk/...` are not mistaken for relative-path markers. + // `.tailor/...` are not mistaken for relative-path markers. const displaySource = rel.startsWith("..") ? rel : `./${rel}`; const fnName = name ?? frame.original.functionName; const link = buildSourceLink(displaySource, absolutePath, line, column); diff --git a/packages/sdk/src/cli/shared/trigger-context.test.ts b/packages/sdk/src/cli/shared/start-context.test.ts similarity index 67% rename from packages/sdk/src/cli/shared/trigger-context.test.ts rename to packages/sdk/src/cli/shared/start-context.test.ts index 156fc7041..d0b41870f 100644 --- a/packages/sdk/src/cli/shared/trigger-context.test.ts +++ b/packages/sdk/src/cli/shared/start-context.test.ts @@ -2,30 +2,30 @@ 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 { transformFunctionTriggers } from "#/cli/services/workflow/trigger-transformer"; +import { transformStartCalls } from "#/cli/services/workflow/start-transformer"; import { - buildTriggerContext, + buildStartContext, normalizeFilePath, - serializeTriggerContext, - type TriggerContext, - type TriggerModuleBindings, -} from "./trigger-context"; + serializeStartContext, + type StartContext, + type StartModuleBindings, +} from "./start-context"; -describe("serializeTriggerContext", () => { - function emptyContext(): TriggerContext { +describe("serializeStartContext", () => { + function emptyContext(): StartContext { return { modules: new Map() }; } - function bindings(localBindings: TriggerModuleBindings["localBindings"]) { + function bindings(localBindings: StartModuleBindings["localBindings"]) { return { localBindings, exports: new Map(localBindings) }; } test("returns empty string for undefined", () => { - expect(serializeTriggerContext(undefined)).toBe(""); + expect(serializeStartContext(undefined)).toBe(""); }); test("returns deterministic output for empty maps", () => { - expect(serializeTriggerContext(emptyContext())).toBe("[]"); + expect(serializeStartContext(emptyContext())).toBe("[]"); }); test("returns same output regardless of map insertion order", () => { @@ -49,7 +49,7 @@ describe("serializeTriggerContext", () => { bindings(new Map([["workflow", { kind: "workflow", name: "WorkflowB" }]])), ); - expect(serializeTriggerContext(first)).toBe(serializeTriggerContext(second)); + expect(serializeStartContext(first)).toBe(serializeStartContext(second)); }); test("returns different output when map content differs", () => { @@ -61,7 +61,7 @@ describe("serializeTriggerContext", () => { bindings(new Map([["job", { kind: "job", name: "ProcessPayment" }]])), ); - expect(serializeTriggerContext(first)).not.toBe(serializeTriggerContext(second)); + expect(serializeStartContext(first)).not.toBe(serializeStartContext(second)); }); test("distinguishes entries in different maps", () => { @@ -73,11 +73,11 @@ describe("serializeTriggerContext", () => { const second = emptyContext(); second.modules.set("/module", bindings(new Map([["target", { kind: "job", name: "Name" }]]))); - expect(serializeTriggerContext(first)).not.toBe(serializeTriggerContext(second)); + expect(serializeStartContext(first)).not.toBe(serializeStartContext(second)); }); }); -describe("buildTriggerContext", () => { +describe("buildStartContext", () => { const tempDirs: string[] = []; afterEach(() => { @@ -87,7 +87,7 @@ describe("buildTriggerContext", () => { }); async function createDuplicateExportContext() { - const tempDir = mkdtempSync(path.join(tmpdir(), "trigger-context-")); + const tempDir = mkdtempSync(path.join(tmpdir(), "start-context-")); tempDirs.push(tempDir); const firstPath = path.join(tempDir, "first.ts"); const secondPath = path.join(tempDir, "second.ts"); @@ -101,34 +101,34 @@ export const step = createWorkflowJob({ name: "step-b", body: async () => "b" }) `; writeFileSync(firstPath, firstSource); writeFileSync(secondPath, secondSource); - const context = await buildTriggerContext({ files: [firstPath, secondPath] }); + const context = await buildStartContext({ files: [firstPath, secondPath] }); return { context, firstPath, firstSource, tempDir }; } - function transform(source: string, currentFilePath: string, context: TriggerContext) { - return transformFunctionTriggers(source, context, currentFilePath); + function transform(source: string, currentFilePath: string, context: StartContext) { + return transformStartCalls(source, context, currentFilePath); } test("resolves duplicate local export names from the current module", async () => { const { context, firstPath, firstSource } = await createDuplicateExportContext(); - const result = transform(`${firstSource}\nawait step.trigger();\n`, firstPath, context); + const result = transform(`${firstSource}\nawait step.start();\n`, firstPath, context); - expect(result).toContain('tailor.workflow.triggerJobFunction("step-a", undefined)'); - expect(result).not.toContain('tailor.workflow.triggerJobFunction("step-b"'); + expect(result).toContain('tailor.workflow.startJobFunction("step-a", undefined)'); + expect(result).not.toContain('tailor.workflow.startJobFunction("step-b"'); }); test("resolves aliased named imports", async () => { const { context, tempDir } = await createDuplicateExportContext(); const source = ` import { step as importedStep } from "./first"; -await importedStep.trigger(); +await importedStep.start(); `; const result = transform(source, path.join(tempDir, "caller.ts"), context); - expect(result).toContain('tailor.workflow.triggerJobFunction("step-a", undefined)'); - expect(result).not.toContain("importedStep.trigger()"); + expect(result).toContain('tailor.workflow.startJobFunction("step-a", undefined)'); + expect(result).not.toContain("importedStep.start()"); }); test("resolves relative default workflow imports", async () => { @@ -142,16 +142,16 @@ const mainJob = createWorkflowJob({ name: "main-job", body: async () => "done" } export default createWorkflow({ name: "workflow-a", mainJob }); `, ); - const context = await buildTriggerContext({ files: [workflowPath] }); + const context = await buildStartContext({ files: [workflowPath] }); const source = ` import workflow from "./workflow"; -await workflow.trigger(); +await workflow.start(); `; const result = transform(source, path.join(tempDir, "caller.ts"), context); expect(result).toContain('import workflow from "./workflow"'); - expect(result).toContain('tailor.workflow.triggerWorkflow("workflow-a", undefined)'); + expect(result).toContain('tailor.workflow.startWorkflow("workflow-a", undefined)'); }); test("preserves a namespace import paired with a transformed default import", async () => { @@ -165,64 +165,64 @@ const mainJob = createWorkflowJob({ name: "main-job", body: async () => "done" } export default createWorkflow({ name: "workflow-a", mainJob }); `, ); - const context = await buildTriggerContext({ files: [workflowPath] }); + const context = await buildStartContext({ files: [workflowPath] }); const source = ` import workflow, * as helpers from "./workflow"; console.log(helpers); -await workflow.trigger(); +await workflow.start(); `; const result = transform(source, path.join(tempDir, "caller.ts"), context); expect(result).toContain('import workflow, * as helpers from "./workflow"'); expect(result).toContain("console.log(helpers)"); - expect(result).toContain('tailor.workflow.triggerWorkflow("workflow-a", undefined)'); + expect(result).toContain('tailor.workflow.startWorkflow("workflow-a", undefined)'); }); test("does not transform an imported job shadowed by a parameter", async () => { const { context, tempDir } = await createDuplicateExportContext(); const source = ` import { step } from "./first"; -await step.trigger({ source: "outer" }); -async function run(step: { trigger(): Promise }) { - return step.trigger(); +await step.start({ source: "outer" }); +async function run(step: { start(): Promise }) { + return step.start(); } `; const result = transform(source, path.join(tempDir, "caller.ts"), context); - expect(result).toContain('triggerJobFunction("step-a", { source: "outer" })'); - expect(result).toContain("return step.trigger()"); + expect(result).toContain('startJobFunction("step-a", { source: "outer" })'); + expect(result).toContain("return step.start()"); }); test("does not transform an imported job shadowed by a local variable", async () => { const { context, tempDir } = await createDuplicateExportContext(); const source = ` import { step } from "./first"; -await step.trigger({ source: "outer" }); +await step.start({ source: "outer" }); async function run() { - const step = { trigger: async () => "local" }; - return step.trigger(); + const step = { start: async () => "local" }; + return step.start(); } `; const result = transform(source, path.join(tempDir, "caller.ts"), context); - expect(result).toContain('triggerJobFunction("step-a", { source: "outer" })'); - expect(result).toContain("return step.trigger()"); + expect(result).toContain('startJobFunction("step-a", { source: "outer" })'); + expect(result).toContain("return step.start()"); }); - test("does not transform a local object with a trigger method", async () => { + test("does not transform a local object with a start method", async () => { const { context, tempDir } = await createDuplicateExportContext(); const source = ` -const step = { trigger: async () => "local" }; -await step.trigger(); +const step = { start: async () => "local" }; +await step.start(); `; const result = transform(source, path.join(tempDir, "caller.ts"), context); - expect(result).toContain("await step.trigger()"); - expect(result).not.toContain("tailor.workflow.triggerJobFunction"); + expect(result).toContain("await step.start()"); + expect(result).not.toContain("tailor.workflow.startJobFunction"); }); test("escapes job and workflow names in generated calls", async () => { @@ -231,8 +231,8 @@ await step.trigger(); const jobName = 'step"\\quoted'; currentModule?.localBindings.set("step", { kind: "job", name: jobName }); - const result = transform(`${firstSource}\nawait step.trigger();\n`, firstPath, context); + const result = transform(`${firstSource}\nawait step.start();\n`, firstPath, context); - expect(result).toContain(`triggerJobFunction(${JSON.stringify(jobName)}, undefined)`); + expect(result).toContain(`startJobFunction(${JSON.stringify(jobName)}, undefined)`); }); }); diff --git a/packages/sdk/src/cli/shared/trigger-context.ts b/packages/sdk/src/cli/shared/start-context.ts similarity index 80% rename from packages/sdk/src/cli/shared/trigger-context.ts rename to packages/sdk/src/cli/shared/start-context.ts index 4d3eb069c..15a43a087 100644 --- a/packages/sdk/src/cli/shared/trigger-context.ts +++ b/packages/sdk/src/cli/shared/start-context.ts @@ -7,23 +7,23 @@ import { findAllJobs } from "#/cli/services/workflow/job-detector"; import { findAllWorkflows } from "#/cli/services/workflow/workflow-detector"; import { logger } from "#/cli/shared/logger"; -export interface TriggerTarget { +export interface StartTarget { kind: "job" | "workflow"; name: string; } -export interface TriggerModuleBindings { - localBindings: Map; - exports: Map; +export interface StartModuleBindings { + localBindings: Map; + exports: Map; } -export interface TriggerContext { - modules: Map; +export interface StartContext { + modules: Map; authNamespace?: string; } /** - * Normalize a source module path for trigger binding lookup. + * Normalize a source module path for start-call binding lookup. * @param filePath - Source file path or extensionless relative import path * @returns Absolute path without a JavaScript or TypeScript extension */ @@ -32,8 +32,8 @@ export function normalizeFilePath(filePath: string): string { } function createModuleBindings(program: ReturnType["program"], source: string) { - const localBindings = new Map(); - const exports = new Map(); + const localBindings = new Map(); + const exports = new Map(); for (const workflow of findAllWorkflows(program, source)) { const target = { kind: "workflow", name: workflow.name } as const; @@ -79,22 +79,22 @@ function createModuleBindings(program: ReturnType["program"], } } - return { localBindings, exports } satisfies TriggerModuleBindings; + return { localBindings, exports } satisfies StartModuleBindings; } /** - * Build trigger context from configured workflow source files. + * Build start-call context from configured workflow source files. * @param workflowConfig - Workflow file loading configuration - * @param authNamespace - Auth service namespace used by workflow trigger options + * @param authNamespace - Auth service namespace (optional, used for string-literal invoker expansion) * @param baseDir - Directory the workflow config's file patterns are resolved against (defaults to process.cwd()) * @returns Module-local workflow and job binding metadata */ -export async function buildTriggerContext( +export async function buildStartContext( workflowConfig: FileLoadConfig | undefined, authNamespace?: string, baseDir = process.cwd(), -): Promise { - const modules = new Map(); +): Promise { + const modules = new Map(); if (!workflowConfig) return { modules, authNamespace }; for (const file of loadFilesWithIgnores(workflowConfig, baseDir)) { @@ -113,18 +113,18 @@ export async function buildTriggerContext( return { modules, authNamespace }; } -function sortedTargets(bindings: Map) { +function sortedTargets(bindings: Map) { return [...bindings] .toSorted(([a], [b]) => a.localeCompare(b)) .map(([binding, target]) => [binding, target.kind, target.name]); } /** - * Serialize trigger context to a deterministic cache input. - * @param context - Trigger context to serialize + * Serialize start-call context to a deterministic cache input. + * @param context - Start-call context to serialize * @returns Deterministic string, or an empty string when context is absent */ -export function serializeTriggerContext(context: TriggerContext | undefined): string { +export function serializeStartContext(context: StartContext | undefined): string { if (!context) return ""; const modules = [...context.modules] .toSorted(([a], [b]) => a.localeCompare(b)) diff --git a/packages/sdk/src/cli/shared/tailordb-namespaces.ts b/packages/sdk/src/cli/shared/tailordb-namespaces.ts new file mode 100644 index 000000000..c919cf987 --- /dev/null +++ b/packages/sdk/src/cli/shared/tailordb-namespaces.ts @@ -0,0 +1,84 @@ +import { defineApplication } from "#/cli/services/application"; +import { PluginManager } from "#/plugin/manager"; +import { loadConfig, type LoadedConfig } from "./config-loader"; +import { generateUserTypes } from "./type-generator"; +import type { TailorDBNamespaceData } from "#/plugin/types"; + +/** + * Namespace selection for {@link loadTailorDBNamespaces}: explicit namespace + * names, or a selector deriving them from the loaded config. Returning + * `undefined` (or omitting the option) loads all owned namespaces. + */ +export type TailorDBNamespaceSelector = string[] | ((config: LoadedConfig) => string[] | undefined); + +/** + * Options for {@link loadTailorDBNamespaces}. + */ +export interface LoadTailorDBNamespacesOptions { + /** Path to tailor.config.ts. Defaults to searching from the current directory. */ + configPath?: string; + /** Namespaces to load. Omit to load all owned namespaces. */ + namespaces?: TailorDBNamespaceSelector; +} + +/** + * Result of {@link loadTailorDBNamespaces}. + */ +export interface LoadedTailorDBNamespaces { + /** The loaded Tailor config. */ + config: LoadedConfig; + /** Loaded TailorDB namespace data, in config order. */ + namespaces: TailorDBNamespaceData[]; +} + +/** + * 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 } = await loadConfig(options.configPath); + + await generateUserTypes({ config, configPath: config.path }); + + const pluginManager = plugins.length > 0 ? new PluginManager(plugins) : undefined; + const application = defineApplication({ + config, + pluginManager, + }); + const namespaceNames = + typeof options.namespaces === "function" ? options.namespaces(config) : options.namespaces; + const namespaceFilter = namespaceNames ? new Set(namespaceNames) : undefined; + const services = namespaceFilter + ? application.tailorDBServices.filter((db) => namespaceFilter.has(db.namespace)) + : application.tailorDBServices; + + if (namespaceFilter && services.length !== namespaceFilter.size) { + const found = new Set(services.map((db) => db.namespace)); + const missing = [...namespaceFilter].filter((ns) => !found.has(ns)).join(", "); + const available = application.tailorDBServices.map((db) => db.namespace).join(", "); + throw new Error( + `TailorDB namespace "${missing}" not found in local config.db.` + + (available ? ` Available owned namespaces: ${available}` : ""), + ); + } + + const namespaces: TailorDBNamespaceData[] = []; + + for (const db of services) { + await db.loadTypes(); + await db.processNamespacePlugins(); + namespaces.push({ + namespace: db.namespace, + types: { ...db.types }, + sourceInfo: new Map(Object.entries(db.typeSourceInfo)), + pluginAttachments: db.pluginAttachments, + }); + } + + return { config, namespaces }; +} diff --git a/packages/sdk/src/cli/shared/type-generator.test.ts b/packages/sdk/src/cli/shared/type-generator.test.ts index bae73565a..849eb346f 100644 --- a/packages/sdk/src/cli/shared/type-generator.test.ts +++ b/packages/sdk/src/cli/shared/type-generator.test.ts @@ -7,7 +7,7 @@ import { generateTypeDefinition, resolveTypeDefinitionPath, } from "./type-generator"; -import type { AttributeListConfig, AttributeMapConfig } from "./type-generator"; +import type { AttributeListConfig, AttributesConfig } from "./type-generator"; describe("generateTypeDefinition", () => { test.each<{ @@ -21,9 +21,9 @@ describe("generateTypeDefinition", () => { expected: ["__tuple?: [string, string]"], }, { - name: "generates AttributeMap interface", + name: "generates Attributes interface", args: [{ role: { type: '"MANAGER" | "STAFF"' }, isActive: { type: "boolean" } }, undefined], - expected: ["interface AttributeMap", 'role: "MANAGER" | "STAFF"', "isActive: boolean"], + expected: ["interface Attributes", 'role: "MANAGER" | "STAFF"', "isActive: boolean"], }, { name: "generates optional key for an optional-field-derived attribute", @@ -31,9 +31,9 @@ describe("generateTypeDefinition", () => { expected: ["nickname?: string;"], }, { - name: "generates empty AttributeMap when no attributes", + name: "generates empty Attributes when no attributes", args: [undefined, undefined], - expected: ["interface AttributeMap {}", "interface AttributeList", "__tuple?: []"], + expected: ["interface Attributes {}", "interface AttributeList", "__tuple?: []"], }, { name: "includes proper file header and structure", @@ -77,18 +77,75 @@ describe("generateTypeDefinition", () => { }); test("should generate interface AttributeList for declaration merging", () => { - const attributeMap: AttributeMapConfig = { + const attributes: AttributesConfig = { role: { type: '"MANAGER" | "STAFF"' }, }; const attributeList: AttributeListConfig = []; - const result = generateTypeDefinition(attributeMap, attributeList); + const result = generateTypeDefinition(attributes, attributeList); expect(result).toContain("interface AttributeList"); expect(result).not.toContain("type AttributeList ="); expect(result).toContain("__tuple?: []"); }); + test("should generate Attributes interface", () => { + const attributes: AttributesConfig = { + role: { type: '"MANAGER" | "STAFF"' }, + isActive: { type: "boolean" }, + }; + + const result = generateTypeDefinition(attributes, undefined); + + expect(result).toContain("interface Attributes"); + expect(result).toContain('role: "MANAGER" | "STAFF"'); + expect(result).toContain("isActive: boolean"); + }); + + test("should generate empty Attributes when no attributes", () => { + const result = generateTypeDefinition(undefined, undefined); + + expect(result).toContain("interface Attributes {}"); + expect(result).toContain("interface AttributeList"); + expect(result).toContain("__tuple?: []"); + }); + + test("should include proper file header and structure", () => { + const result = generateTypeDefinition(undefined, undefined); + + expect(result).toContain("// This file is auto-generated"); + expect(result).toContain('declare module "@tailor-platform/sdk"'); + expect(result).toContain("export {};"); + }); + + test("should generate Env interface with literal types", () => { + const env = { + hoge: 1, + fuga: "hello", + piyo: true, + }; + + const result = generateTypeDefinition(undefined, undefined, env); + + expect(result).toContain("interface Env"); + expect(result).toContain("hoge: 1;"); + expect(result).toContain('fuga: "hello";'); + expect(result).toContain("piyo: true;"); + }); + + test("should generate empty Env interface when no env provided", () => { + const result = generateTypeDefinition(undefined, undefined); + + expect(result).toContain("interface Env {}"); + }); + + test("should generate empty MachineUserNameRegistry when no machine users provided", () => { + const result = generateTypeDefinition(undefined, undefined); + + expect(result).toContain("interface MachineUserNameRegistry {}"); + expect(result).not.toContain('declare module "@tailor-platform/sdk/cli"'); + }); + test("should generate MachineUserNameRegistry with machine user names", () => { const result = generateTypeDefinition(undefined, undefined, undefined, [ "manager-machine-user", @@ -96,6 +153,7 @@ describe("generateTypeDefinition", () => { ]); expect(result).toContain("interface MachineUserNameRegistry"); + expect(result).not.toContain('declare module "@tailor-platform/sdk/cli"'); // Names with hyphens are quoted expect(result).toContain('"manager-machine-user": true;'); // Valid identifiers are emitted unquoted (matches formatter output) @@ -144,17 +202,17 @@ describe("generateTypeDefinition", () => { }); describe("resolveTypeDefinitionPath", () => { - const originalEnv = process.env.TAILOR_PLATFORM_SDK_DTS_PATH; + const originalEnv = process.env.TAILOR_DTS_PATH; beforeEach(() => { - delete process.env.TAILOR_PLATFORM_SDK_DTS_PATH; + delete process.env.TAILOR_DTS_PATH; }); afterEach(() => { if (originalEnv !== undefined) { - process.env.TAILOR_PLATFORM_SDK_DTS_PATH = originalEnv; + process.env.TAILOR_DTS_PATH = originalEnv; } else { - delete process.env.TAILOR_PLATFORM_SDK_DTS_PATH; + delete process.env.TAILOR_DTS_PATH; } }); @@ -163,26 +221,27 @@ describe("resolveTypeDefinitionPath", () => { expect(result).toBe(path.resolve("/project", "tailor.d.ts")); }); - test("should use TAILOR_PLATFORM_SDK_DTS_PATH when set to an absolute path", () => { - process.env.TAILOR_PLATFORM_SDK_DTS_PATH = "/custom/output/types.d.ts"; + test("should use TAILOR_DTS_PATH when set to an absolute path", () => { + process.env.TAILOR_DTS_PATH = "/custom/output/types.d.ts"; const result = resolveTypeDefinitionPath("/project/tailor.config.ts"); expect(result).toBe("/custom/output/types.d.ts"); }); - test("should resolve TAILOR_PLATFORM_SDK_DTS_PATH relative to cwd when relative", () => { - process.env.TAILOR_PLATFORM_SDK_DTS_PATH = "custom/types.d.ts"; + test("should resolve TAILOR_DTS_PATH relative to cwd when relative", () => { + process.env.TAILOR_DTS_PATH = "custom/types.d.ts"; const result = resolveTypeDefinitionPath("/project/tailor.config.ts"); expect(result).toBe(path.resolve("custom/types.d.ts")); }); }); describe("extractAttributesFromConfig + generateTypeDefinition", () => { - test("renders machineUserAttributes into AttributeMap", () => { + test("renders machineUserAttributes into Attributes", () => { const config = { name: "test-app", auth: defineAuth("auth", { machineUserAttributes: { role: t.enum(["ADMIN", "WORKER"]), + roles: t.enum(["ADMIN", "WORKER"], { array: true }), isActive: t.bool(), tags: t.string({ array: true }), nickname: t.string({ optional: true }), @@ -191,6 +250,7 @@ describe("extractAttributesFromConfig + generateTypeDefinition", () => { admin: { attributes: { role: "ADMIN", + roles: ["ADMIN"], isActive: true, tags: ["root"], }, @@ -199,10 +259,11 @@ describe("extractAttributesFromConfig + generateTypeDefinition", () => { }), }; - const { attributeMap } = extractAttributesFromConfig(config); - const content = generateTypeDefinition(attributeMap, undefined); + const { attributes } = extractAttributesFromConfig(config); + const content = generateTypeDefinition(attributes, undefined); expect(content).toContain('role: "ADMIN" | "WORKER";'); + expect(content).toContain('roles: ("ADMIN" | "WORKER")[];'); expect(content).toContain("isActive: boolean;"); expect(content).toContain("tags: string[];"); // A field derived from an optional source field renders as an optional key, @@ -225,10 +286,10 @@ describe("extractAttributesFromConfig + generateTypeDefinition", () => { }), }; - const { attributeMap, machineUserNames } = extractAttributesFromConfig(config); + const { attributes, machineUserNames } = extractAttributesFromConfig(config); expect(machineUserNames).toEqual(["admin", "worker"]); - const content = generateTypeDefinition(attributeMap, undefined, undefined, machineUserNames); + const content = generateTypeDefinition(attributes, undefined, undefined, machineUserNames); expect(content).toContain("interface MachineUserNameRegistry"); expect(content).toContain("admin: true;"); expect(content).toContain("worker: true;"); diff --git a/packages/sdk/src/cli/shared/type-generator.ts b/packages/sdk/src/cli/shared/type-generator.ts index 156e8e0a8..ce1b8eb5b 100644 --- a/packages/sdk/src/cli/shared/type-generator.ts +++ b/packages/sdk/src/cli/shared/type-generator.ts @@ -9,14 +9,14 @@ export interface AttributeTypeInfo { optional?: boolean; } -export interface AttributeMapConfig { +export interface AttributesConfig { [key: string]: AttributeTypeInfo; } export type AttributeListConfig = readonly string[]; interface ExtractedAttributes { - attributeMap?: AttributeMapConfig; + attributes?: AttributesConfig; attributeList?: AttributeListConfig; env?: Record; machineUserNames?: string[]; @@ -37,7 +37,7 @@ type AttributeFieldLike = { /** * Extract attribute definitions from the app config for user-defined typing. * @param config - Application config to inspect - * @returns Extracted attribute map/list and env values + * @returns Extracted attributes/list and env values * @internal */ export function extractAttributesFromConfig(config: AppConfig): ExtractedAttributes { @@ -46,17 +46,17 @@ export function extractAttributesFromConfig(config: AppConfig): ExtractedAttribu /** * Generate the contents of the user-defined type definition file. - * @param attributeMap - Attribute map configuration + * @param attributes - Attribute configuration * @param attributeList - Attribute list configuration * @param env - Environment configuration - * @param machineUserNames - Registered machine user names (used to narrow `authInvoker` strings) + * @param machineUserNames - Registered machine user names (used to narrow `invoker` strings) * @param idpNames - Registered IdP names (used to narrow `idpUser*Trigger({ idp })` strings) * @param connectionNames - Registered auth connection names (used to narrow `getConnectionToken()` strings) * @param aiGatewayNames - Registered AI Gateway names (used to narrow `aigateway.get()` strings) * @returns Generated type definition source */ export function generateTypeDefinition( - attributeMap: AttributeMapConfig | undefined, + attributes: AttributesConfig | undefined, attributeList: AttributeListConfig | undefined, env?: Record, machineUserNames?: readonly string[], @@ -64,20 +64,20 @@ export function generateTypeDefinition( connectionNames?: readonly string[], aiGatewayNames?: readonly string[], ): string { - // Generate AttributeMap interface - // attributeMap values carry a type string representation (e.g., "string", "boolean", "string[]") + // Generate Attributes interface + // attributes values carry a type string representation (e.g., "string", "boolean", "string[]") // and whether the underlying field is optional, so the key mirrors that optionality. - const mapFields = attributeMap - ? Object.entries(attributeMap) + const attributeFields = attributes + ? Object.entries(attributes) .map(([key, { type, optional }]) => ` ${key}${optional ? "?" : ""}: ${type};`) .join("\n") : ""; - const mapBody = - !attributeMap || Object.keys(attributeMap).length === 0 + const attributesBody = + !attributes || Object.keys(attributes).length === 0 ? "{}" : `{ -${mapFields} +${attributeFields} }`; // Generate AttributeList type as a tuple of strings based on the length @@ -167,10 +167,10 @@ ${aiGatewayNameFields} return ml /* ts */ ` // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap ${mapBody} + interface Attributes ${attributesBody} interface AttributeList ${listBody} interface Env ${envBody} interface MachineUserNameRegistry ${machineUserBody} @@ -231,7 +231,7 @@ function collectAttributesFromConfig(config: AppConfig): ExtractedAttributes { // Add array suffix if needed if (metadata.array) { - typeStr += "[]"; + typeStr = typeStr.includes(" | ") ? `(${typeStr})[]` : `${typeStr}[]`; } return { type: typeStr, optional: metadata.required === false }; @@ -251,20 +251,20 @@ function collectAttributesFromConfig(config: AppConfig): ExtractedAttributes { } ).userProfile; - const attributes = userProfile?.attributes; + const selectedAttributes = userProfile?.attributes; const fields = userProfile?.type?.fields; const attributeList = userProfile?.attributeList; - // Convert attributes to AttributeMapConfig by inferring types from field metadata - const attributeMap: AttributeMapConfig | undefined = attributes - ? Object.keys(attributes).reduce((acc, key) => { + // Convert attributes to AttributesConfig by inferring types from field metadata + const attributes: AttributesConfig | undefined = selectedAttributes + ? Object.keys(selectedAttributes).reduce((acc, key) => { acc[key] = inferAttributeType(fields?.[key]); return acc; - }, {} as AttributeMapConfig) + }, {} as AttributesConfig) : undefined; return { - attributeMap, + attributes, attributeList, machineUserNames, idpNames, @@ -284,13 +284,13 @@ function collectAttributesFromConfig(config: AppConfig): ExtractedAttributes { return { machineUserNames, idpNames, connectionNames, aiGatewayNames }; } - const attributeMap = Object.entries(machineUserAttributes).reduce((acc, [key, field]) => { + const attributes = Object.entries(machineUserAttributes).reduce((acc, [key, field]) => { acc[key] = inferAttributeType(field); return acc; - }, {} as AttributeMapConfig); + }, {} as AttributesConfig); return { - attributeMap, + attributes, machineUserNames, idpNames, connectionNames, @@ -304,14 +304,14 @@ function collectAttributesFromConfig(config: AppConfig): ExtractedAttributes { /** * Resolve the output path for the generated type definition file. * - * When the `TAILOR_PLATFORM_SDK_DTS_PATH` environment variable is set, the value is + * When the `TAILOR_DTS_PATH` environment variable is set, the value is * used as the output path (resolved relative to cwd when relative). * Otherwise, the file is written next to the config file as `tailor.d.ts`. * @param configPath - Path to Tailor config file * @returns Absolute path to the type definition file */ export function resolveTypeDefinitionPath(configPath: string): string { - const envPath = process.env.TAILOR_PLATFORM_SDK_DTS_PATH; + const envPath = process.env.TAILOR_DTS_PATH; if (envPath) { return path.resolve(envPath); } @@ -337,19 +337,19 @@ export async function generateUserTypes(options: GenerateUserTypesOptions): Prom const { config, configPath } = options; try { const { - attributeMap, + attributes, attributeList, machineUserNames, idpNames, connectionNames, aiGatewayNames, } = extractAttributesFromConfig(config); - if (!attributeMap && !attributeList) { + if (!attributes && !attributeList) { logger.info("No attributes found in configuration", { mode: "plain" }); } - if (attributeMap) { - logger.debug(`Extracted AttributeMap: ${JSON.stringify(attributeMap)}`); + if (attributes) { + logger.debug(`Extracted Attributes: ${JSON.stringify(attributes)}`); } if (attributeList) { logger.debug(`Extracted AttributeList: ${JSON.stringify(attributeList)}`); @@ -374,7 +374,7 @@ export async function generateUserTypes(options: GenerateUserTypesOptions): Prom // Generate type definition const typeDefContent = generateTypeDefinition( - attributeMap, + attributes, attributeList, env, machineUserNames, diff --git a/packages/sdk/src/cli/shared/user-agent.ts b/packages/sdk/src/cli/shared/user-agent.ts index 38bfb8f78..f9836ed06 100644 --- a/packages/sdk/src/cli/shared/user-agent.ts +++ b/packages/sdk/src/cli/shared/user-agent.ts @@ -8,7 +8,7 @@ import { readPackageJson } from "./package-json"; * @returns User-Agent header value */ export function userAgentFromVersion(version: string): string { - return `tailor-sdk/${version}`; + return `tailor/${version}`; } /** diff --git a/packages/sdk/src/cli/skills-command.test.ts b/packages/sdk/src/cli/skills-command.test.ts new file mode 100644 index 000000000..fa538f166 --- /dev/null +++ b/packages/sdk/src/cli/skills-command.test.ts @@ -0,0 +1,186 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "pathe"; +import { extractFields, isLazyCommand, runCommand } from "politty"; +import { describe, expect, test, vi } from "vitest"; +import { z } from "zod"; +import { commonArgs } from "./shared/args"; +import { logger } from "./shared/logger"; +import { tempCwd } from "./shared/test-helpers/temp-cwd"; +import { mainCommand } from "./index"; +import type { AnyCommand, RunResult, SubCommandValue } from "politty"; + +vi.mock("node:module", async () => { + const actual = await vi.importActual("node:module"); + return { ...actual, register: vi.fn() }; +}); + +vi.mock("politty", async () => { + const actual = await vi.importActual("politty"); + return { ...actual, runMain: vi.fn() }; +}); + +// strip unknown global args to match the CLI runner. +const testGlobalArgs = z.object(commonArgs); + +async function resolveCommand(cmd: SubCommandValue): Promise { + if (isLazyCommand(cmd)) { + return await cmd.load(); + } + if (typeof cmd === "function") { + return await cmd(); + } + return cmd; +} + +function expectDefined(value: T | undefined): T { + expect(value).toBeDefined(); + return value as T; +} + +function expectCommandFailure(result: RunResult, message: string): void { + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected command to fail"); + } + expect(result.error.message).toContain(message); +} + +async function importMainCommandForCurrentCwd(): Promise { + vi.resetModules(); + const module = await import("./index"); + return module.mainCommand; +} + +describe("skills command", () => { + test("uses politty skill management subcommands without legacy aliases", async () => { + const skillsCommand = await resolveCommand(expectDefined(mainCommand.subCommands.skills)); + const skillSubCommands = expectDefined(skillsCommand.subCommands); + const addCommand = await resolveCommand(expectDefined(skillSubCommands.add)); + const removeCommand = await resolveCommand(expectDefined(skillSubCommands.remove)); + + expect(Object.keys(skillSubCommands).toSorted()).toEqual(["add", "list", "remove", "sync"]); + expect(skillsCommand.run).toBeTypeOf("function"); + expect(addCommand.aliases).toBeUndefined(); + expect(removeCommand.aliases).toBeUndefined(); + expect( + extractFields(expectDefined(addCommand.args)).fields.map((field) => field.name), + ).not.toEqual(expect.arrayContaining(["agent", "yes"])); + }); + + test("does not shadow inherited global flags with local defaults", async () => { + const skillsCommand = await resolveCommand(expectDefined(mainCommand.subCommands.skills)); + const skillSubCommands = expectDefined(skillsCommand.subCommands); + const addCommand = await resolveCommand(expectDefined(skillSubCommands.add)); + const listCommand = await resolveCommand(expectDefined(skillSubCommands.list)); + + expect(expectDefined(addCommand.args).parse({})).not.toHaveProperty("verbose"); + expect(expectDefined(listCommand.args).parse({})).not.toHaveProperty("json"); + + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const result = await runCommand(mainCommand, ["--json", "skills", "list"], { + globalArgs: testGlobalArgs, + captureLogs: true, + }); + + expect(result.success).toBe(true); + expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining('"name":"tailor"')); + } finally { + consoleLog.mockRestore(); + logger.jsonMode = false; + } + }); + + test("rejects removed legacy forms before side effects", async () => { + using tmp = tempCwd("tailor-skills-command-"); + writeFileSync(join(tmp.dir, "package.json"), "{}\n"); + const cwdMainCommand = await importMainCommandForCurrentCwd(); + + const installResult = await runCommand(cwdMainCommand, ["skills", "install"], { + globalArgs: testGlobalArgs, + captureLogs: true, + }); + expectCommandFailure(installResult, "Unknown subcommand"); + expect(existsSync(join(tmp.dir, ".agents/skills/tailor"))).toBe(false); + + const agentResult = await runCommand(cwdMainCommand, ["skills", "add", "--agent", "codex"], { + globalArgs: testGlobalArgs, + captureLogs: true, + }); + expectCommandFailure(agentResult, "Unknown flags: agent"); + expect(existsSync(join(tmp.dir, ".agents/skills/tailor"))).toBe(false); + }); + + test("rejects unknown skill flags before side effects", async () => { + using tmp = tempCwd("tailor-skills-command-"); + writeFileSync(join(tmp.dir, "package.json"), "{}\n"); + const cwdMainCommand = await importMainCommandForCurrentCwd(); + + const addResult = await runCommand(cwdMainCommand, ["skills", "add", "--agnt", "codex"], { + globalArgs: testGlobalArgs, + captureLogs: true, + }); + + expectCommandFailure(addResult, "Unknown flags: agnt"); + expect(existsSync(join(tmp.dir, ".agents/skills/tailor"))).toBe(false); + + const installResult = await runCommand(cwdMainCommand, ["skills", "add"], { + globalArgs: testGlobalArgs, + captureLogs: true, + }); + expect(installResult.success).toBe(true); + + const removeResult = await runCommand(cwdMainCommand, ["skills", "remove", "--nam", "nope"], { + globalArgs: testGlobalArgs, + captureLogs: true, + }); + expectCommandFailure(removeResult, "Unknown flags: nam"); + expect(existsSync(join(tmp.dir, ".agents/skills/tailor"))).toBe(true); + + const syncResult = await runCommand(cwdMainCommand, ["skills", "sync", "--exlude", "tailor"], { + globalArgs: testGlobalArgs, + captureLogs: true, + }); + expectCommandFailure(syncResult, "Unknown flags: exlude"); + expect(existsSync(join(tmp.dir, ".agents/skills/tailor"))).toBe(true); + }); + + test("propagates default install failures from the skills command", async () => { + using tmp = tempCwd("tailor-skills-command-"); + writeFileSync(join(tmp.dir, "package.json"), "{}\n"); + const cwdMainCommand = await importMainCommandForCurrentCwd(); + mkdirSync(join(tmp.dir, ".agents/skills/tailor"), { recursive: true }); + writeFileSync( + join(tmp.dir, ".agents/skills/tailor/SKILL.md"), + "---\nname: tailor\ndescription: manual\n---\n# Manual\n", + ); + + const result = await runCommand(cwdMainCommand, ["skills"], { + globalArgs: testGlobalArgs, + captureLogs: true, + }); + + expect(result.success).toBe(false); + expectCommandFailure(result, 'Refusing to install "tailor"'); + }); + + test("removes installed Tailor skills", async () => { + using tmp = tempCwd("tailor-skills-command-"); + writeFileSync(join(tmp.dir, "package.json"), "{}\n"); + const cwdMainCommand = await importMainCommandForCurrentCwd(); + + const addResult = await runCommand(cwdMainCommand, ["skills", "add"], { + globalArgs: testGlobalArgs, + captureLogs: true, + }); + expect(addResult.success).toBe(true); + expect(existsSync(join(tmp.dir, ".agents/skills/tailor/SKILL.md"))).toBe(true); + + const removeResult = await runCommand(cwdMainCommand, ["skills", "remove"], { + globalArgs: testGlobalArgs, + captureLogs: true, + }); + expect(removeResult.success).toBe(true); + expect(existsSync(join(tmp.dir, ".agents/skills/tailor"))).toBe(false); + }); +}); diff --git a/packages/sdk/src/cli/skills.ts b/packages/sdk/src/cli/skills.ts deleted file mode 100644 index bda77fc16..000000000 --- a/packages/sdk/src/cli/skills.ts +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "pathe"; -import { logger } from "./shared/logger"; - -logger.warn( - "`tailor-sdk-skills` is deprecated and will be removed in v2. Use `tailor-sdk skills install` instead.", -); - -const here = dirname(fileURLToPath(import.meta.url)); -const mainCli = resolve(here, "index.mjs"); - -const child = spawn(process.execPath, [mainCli, "skills", "install", ...process.argv.slice(2)], { - stdio: "inherit", -}); - -child.on("exit", (code) => { - process.exit(code ?? 1); -}); diff --git a/packages/sdk/src/cli/telemetry/index.ts b/packages/sdk/src/cli/telemetry/index.ts index d3466ebeb..ecb7a7d8d 100644 --- a/packages/sdk/src/cli/telemetry/index.ts +++ b/packages/sdk/src/cli/telemetry/index.ts @@ -44,7 +44,7 @@ export async function initTelemetry(): Promise { const version = packageJson.version ?? "unknown"; const resource = resourceFromAttributes({ - [ATTR_SERVICE_NAME]: "tailor-sdk", + [ATTR_SERVICE_NAME]: "tailor", [ATTR_SERVICE_VERSION]: version, }); @@ -79,7 +79,7 @@ export async function shutdownTelemetry(): Promise { * @returns Result of fn */ export async function withSpan(name: string, fn: (span: Span) => Promise): Promise { - const tracer = trace.getTracer("tailor-sdk"); + const tracer = trace.getTracer("tailor"); return tracer.startActiveSpan(name, async (span) => { try { diff --git a/packages/sdk/src/cli/telemetry/interceptor.ts b/packages/sdk/src/cli/telemetry/interceptor.ts index 55f4a8b44..6a5f8f5b7 100644 --- a/packages/sdk/src/cli/telemetry/interceptor.ts +++ b/packages/sdk/src/cli/telemetry/interceptor.ts @@ -9,7 +9,7 @@ import type { Interceptor } from "@connectrpc/connect"; */ export function createTracingInterceptor(): Interceptor { return (next) => async (req) => { - const tracer = trace.getTracer("tailor-sdk"); + const tracer = trace.getTracer("tailor"); return tracer.startActiveSpan(`rpc.${req.method.name}`, async (span) => { span.setAttribute("rpc.method", req.method.name); diff --git a/packages/sdk/src/cli/ts-hook.d.mts b/packages/sdk/src/cli/ts-hook.d.mts new file mode 100644 index 000000000..d8daefaa1 --- /dev/null +++ b/packages/sdk/src/cli/ts-hook.d.mts @@ -0,0 +1,4 @@ +export declare function resolve(...args: unknown[]): Promise; +export declare function load(...args: unknown[]): Promise; +export declare function resolveSync(...args: unknown[]): unknown; +export declare function loadSync(...args: unknown[]): unknown; diff --git a/packages/sdk/src/cli/ts-hook.mjs b/packages/sdk/src/cli/ts-hook.mjs new file mode 100644 index 000000000..2392f62e9 --- /dev/null +++ b/packages/sdk/src/cli/ts-hook.mjs @@ -0,0 +1,333 @@ +import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve as resolvePath } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +// Node.js module hook: TypeScript resolver + type stripper via amaro. +// Registered programmatically so the CLI can import user .ts files without +// requiring --experimental-strip-types at process startup. +import { transformSync } from "amaro"; + +const TS_EXTENSIONS = [".ts", ".mts"]; +const JS_TO_TS = new Map([ + [".js", ".ts"], + [".mjs", ".mts"], +]); +const KNOWN_EXTENSIONS = [".ts", ".tsx", ".mts", ".js", ".mjs", ".json"]; + +// --- tsconfig paths resolution --- + +const tsconfigPathsCache = new Map(); + +// Walks the `extends` chain and returns the nearest-defined `baseUrl` (which +// may come from a different config than the one defining `paths`), the +// nearest-defined `paths` object (TypeScript replaces, not merges, inherited +// `paths` once a config defines its own), and the directory of the config +// that defines that `paths` object (the TS 5.0+ fallback base when no +// `baseUrl` is defined anywhere in the chain). +function resolveEffectiveConfig(configFilePath, content, visited) { + if (visited.has(configFilePath)) return {}; + visited.add(configFilePath); + + const baseDir = dirname(configFilePath); + const opts = content.compilerOptions ?? {}; + const ownBaseUrl = + typeof opts.baseUrl === "string" ? resolvePath(baseDir, opts.baseUrl) : undefined; + const ownRawPaths = + opts.paths && typeof opts.paths === "object" && !Array.isArray(opts.paths) + ? opts.paths + : undefined; + + let inherited = {}; + const extendsField = content.extends; + if (typeof extendsField === "string") { + const base = resolvePath(baseDir, extendsField); + const extendsPath = base.endsWith(".json") ? base : base + ".json"; + try { + const sub = JSON.parse(readFileSync(extendsPath, "utf-8")); + inherited = resolveEffectiveConfig(extendsPath, sub, visited); + } catch (e) { + if (e?.code !== "ENOENT" && !(e instanceof SyntaxError)) throw e; + } + } + + return { + baseUrl: ownBaseUrl ?? inherited.baseUrl, + rawPaths: ownRawPaths ?? inherited.rawPaths, + pathsBaseDir: ownRawPaths ? baseDir : inherited.pathsBaseDir, + }; +} + +function collectPathsInto(out, configFilePath, content, visited) { + const { baseUrl, rawPaths, pathsBaseDir } = resolveEffectiveConfig( + configFilePath, + content, + visited, + ); + if (!rawPaths) return; + + const absBase = baseUrl ?? pathsBaseDir ?? dirname(configFilePath); + for (const [alias, targets] of Object.entries(rawPaths)) { + if (!Array.isArray(targets)) continue; + const normalized = targets + .filter((t) => typeof t === "string") + .map((t) => { + const isWildcard = t.endsWith("/*"); + const resolved = resolvePath(absBase, isWildcard ? t.slice(0, -2) : t); + return pathToFileURL(resolved).href + (isWildcard ? "/*" : ""); + }); + if (normalized.length === 0) continue; + out[alias] = normalized; + } +} + +function loadTsconfigPaths(startDir) { + if (tsconfigPathsCache.has(startDir)) return tsconfigPathsCache.get(startDir); + + const paths = Object.create(null); + let dir = startDir; + let prev = ""; + while (dir !== prev) { + try { + const configFilePath = join(dir, "tsconfig.json"); + const content = JSON.parse(readFileSync(configFilePath, "utf-8")); + collectPathsInto(paths, configFilePath, content, new Set()); + break; + } catch (e) { + if (e?.code !== "ENOENT" && !(e instanceof SyntaxError)) throw e; + if (e instanceof SyntaxError) break; + } + prev = dir; + dir = dirname(dir); + } + + tsconfigPathsCache.set(startDir, paths); + return paths; +} + +function matchTsconfigPaths(specifier, paths) { + if (paths[specifier]?.length > 0) { + return paths[specifier]; + } + const wildcardEntries = Object.entries(paths) + .filter(([alias]) => alias.endsWith("/*")) + .toSorted((a, b) => b[0].length - a[0].length); + for (const [alias, targets] of wildcardEntries) { + const prefix = alias.slice(0, -2); + if (specifier.startsWith(prefix + "/")) { + const rest = specifier.slice(prefix.length + 1); + return targets.map((t) => (t.endsWith("/*") ? t.slice(0, -2) + "/" + rest : t)); + } + } + return null; +} + +async function tryResolveWithExtensions(base, context, nextResolve) { + if (!TS_EXTENSIONS.some((ext) => base.endsWith(ext))) { + for (const ext of TS_EXTENSIONS) { + for (const suffix of ["", "/index"]) { + try { + return await nextResolve(base + suffix + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + } + try { + return await nextResolve(base, context); + } catch (e) { + const code = e?.code; + if (code !== "ERR_MODULE_NOT_FOUND" && code !== "ERR_UNSUPPORTED_DIR_IMPORT") throw e; + } + return null; +} + +function tryResolveWithExtensionsSync(base, context, nextResolve) { + if (!TS_EXTENSIONS.some((ext) => base.endsWith(ext))) { + for (const ext of TS_EXTENSIONS) { + for (const suffix of ["", "/index"]) { + try { + return nextResolve(base + suffix + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + } + try { + return nextResolve(base, context); + } catch (e) { + const code = e?.code; + if (code !== "ERR_MODULE_NOT_FOUND" && code !== "ERR_UNSUPPORTED_DIR_IMPORT") throw e; + } + return null; +} + +// --- module hooks --- + +export async function resolve(specifier, context, nextResolve) { + try { + return await nextResolve(specifier, context); + } catch (err) { + if (err.code !== "ERR_MODULE_NOT_FOUND" && err.code !== "ERR_UNSUPPORTED_DIR_IMPORT") throw err; + + if ( + err.code === "ERR_UNSUPPORTED_DIR_IMPORT" && + (specifier.startsWith(".") || specifier.startsWith("/")) + ) { + for (const ext of TS_EXTENSIONS) { + try { + return await nextResolve(specifier + "/index" + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + throw err; + } + + if (!specifier.startsWith(".") && !specifier.startsWith("/")) { + // Non-relative: try tsconfig path aliases + if (context.parentURL?.startsWith("file://")) { + const parentParsed = new URL(context.parentURL); + parentParsed.search = ""; + parentParsed.hash = ""; + const parentDir = dirname(fileURLToPath(parentParsed)); + const tsconfigPaths = loadTsconfigPaths(parentDir); + const candidates = matchTsconfigPaths(specifier, tsconfigPaths); + if (candidates) { + for (const candidate of candidates) { + const result = await tryResolveWithExtensions(candidate, context, nextResolve); + if (result) return result; + } + } + } + throw err; + } + + const lastSegment = specifier.split("/").pop() ?? ""; + if (!KNOWN_EXTENSIONS.some((ext) => lastSegment.endsWith(ext))) { + for (const ext of TS_EXTENSIONS) { + try { + return await nextResolve(specifier + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + + for (const [jsExt, tsExt] of JS_TO_TS) { + if (specifier.endsWith(jsExt)) { + try { + return await nextResolve(specifier.slice(0, -jsExt.length) + tsExt, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + + throw err; + } +} + +export async function load(url, context, nextLoad) { + if (url.startsWith("file:")) { + const parsedUrl = new URL(url); + if ( + TS_EXTENSIONS.some((ext) => parsedUrl.pathname.endsWith(ext)) && + !parsedUrl.pathname.endsWith(".d.ts") && + !parsedUrl.pathname.endsWith(".d.mts") + ) { + parsedUrl.search = ""; + parsedUrl.hash = ""; + const filePath = fileURLToPath(parsedUrl); + const source = await readFile(filePath, "utf-8"); + const { code } = transformSync(source, { mode: "transform", filename: filePath }); + return { format: "module", shortCircuit: true, source: `${code}\n//# sourceURL=${url}` }; + } + } + return nextLoad(url, context); +} + +// Sync hooks for module.registerHooks() (Node >= 22.15.0). +export function resolveSync(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context); + } catch (err) { + if (err.code !== "ERR_MODULE_NOT_FOUND" && err.code !== "ERR_UNSUPPORTED_DIR_IMPORT") throw err; + + if ( + err.code === "ERR_UNSUPPORTED_DIR_IMPORT" && + (specifier.startsWith(".") || specifier.startsWith("/")) + ) { + for (const ext of TS_EXTENSIONS) { + try { + return nextResolve(specifier + "/index" + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + throw err; + } + + if (!specifier.startsWith(".") && !specifier.startsWith("/")) { + // Non-relative: try tsconfig path aliases + if (context.parentURL?.startsWith("file://")) { + const parentParsed = new URL(context.parentURL); + parentParsed.search = ""; + parentParsed.hash = ""; + const parentDir = dirname(fileURLToPath(parentParsed)); + const tsconfigPaths = loadTsconfigPaths(parentDir); + const candidates = matchTsconfigPaths(specifier, tsconfigPaths); + if (candidates) { + for (const candidate of candidates) { + const result = tryResolveWithExtensionsSync(candidate, context, nextResolve); + if (result) return result; + } + } + } + throw err; + } + + const lastSegment = specifier.split("/").pop() ?? ""; + if (!KNOWN_EXTENSIONS.some((ext) => lastSegment.endsWith(ext))) { + for (const ext of TS_EXTENSIONS) { + try { + return nextResolve(specifier + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + + for (const [jsExt, tsExt] of JS_TO_TS) { + if (specifier.endsWith(jsExt)) { + try { + return nextResolve(specifier.slice(0, -jsExt.length) + tsExt, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + + throw err; + } +} + +export function loadSync(url, context, nextLoad) { + if (url.startsWith("file:")) { + const parsedUrl = new URL(url); + if ( + TS_EXTENSIONS.some((ext) => parsedUrl.pathname.endsWith(ext)) && + !parsedUrl.pathname.endsWith(".d.ts") && + !parsedUrl.pathname.endsWith(".d.mts") + ) { + parsedUrl.search = ""; + parsedUrl.hash = ""; + const filePath = fileURLToPath(parsedUrl); + const source = readFileSync(filePath, "utf-8"); + const { code } = transformSync(source, { mode: "transform", filename: filePath }); + return { format: "module", shortCircuit: true, source: `${code}\n//# sourceURL=${url}` }; + } + } + return nextLoad(url, context); +} diff --git a/packages/sdk/src/cli/ts-hook.test.ts b/packages/sdk/src/cli/ts-hook.test.ts new file mode 100644 index 000000000..b4d834405 --- /dev/null +++ b/packages/sdk/src/cli/ts-hook.test.ts @@ -0,0 +1,853 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test, vi } from "vitest"; +import { load, loadSync, resolve, resolveSync } from "./ts-hook.mjs"; + +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn().mockResolvedValue("const x: number = 1;"), +})); + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn().mockReturnValue("const x: number = 1;"), +})); + +vi.mock("amaro", () => ({ + transformSync: vi.fn().mockReturnValue({ code: "const x = 1;" }), +})); + +describe("load", () => { + test("strips query string before fileURLToPath to avoid ERR_INVALID_FILE_URL_PATH", async () => { + const nextLoad = vi.fn(); + const result = await load("file:///path/to/foo.ts?tailorImportNonce=1", {}, nextLoad); + expect(result).toMatchObject({ format: "module", shortCircuit: true }); + expect(nextLoad).not.toHaveBeenCalled(); + }); + + test("strips hash before fileURLToPath", async () => { + const nextLoad = vi.fn(); + const result = await load("file:///path/to/foo.mts#anchor", {}, nextLoad); + expect(result).toMatchObject({ format: "module", shortCircuit: true }); + expect(nextLoad).not.toHaveBeenCalled(); + }); + + test("delegates non-file: URLs to nextLoad", async () => { + const nextLoad = vi.fn(); + await load("node:path", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("node:path", {}); + }); + + test("delegates non-TS file URLs to nextLoad", async () => { + const nextLoad = vi.fn(); + await load("file:///path/to/foo.js", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("file:///path/to/foo.js", {}); + }); + + test("delegates .d.ts declaration files to nextLoad without transforming", async () => { + const nextLoad = vi.fn(); + await load("file:///path/to/foo.d.ts", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("file:///path/to/foo.d.ts", {}); + }); + + test("delegates .d.mts declaration files to nextLoad without transforming", async () => { + const nextLoad = vi.fn(); + await load("file:///path/to/foo.d.mts", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("file:///path/to/foo.d.mts", {}); + }); +}); + +describe("loadSync", () => { + test("strips query string before fileURLToPath to avoid ERR_INVALID_FILE_URL_PATH", () => { + const nextLoad = vi.fn(); + const result = loadSync("file:///path/to/foo.ts?tailorImportNonce=1", {}, nextLoad); + expect(result).toMatchObject({ format: "module", shortCircuit: true }); + expect(nextLoad).not.toHaveBeenCalled(); + }); + + test("delegates non-file: URLs to nextLoad", () => { + const nextLoad = vi.fn(); + loadSync("node:path", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("node:path", {}); + }); + + test("delegates non-TS file URLs to nextLoad", () => { + const nextLoad = vi.fn(); + loadSync("file:///path/to/foo.js", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("file:///path/to/foo.js", {}); + }); + + test("delegates .d.ts declaration files to nextLoad without transforming", () => { + const nextLoad = vi.fn(); + loadSync("file:///path/to/foo.d.ts", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("file:///path/to/foo.d.ts", {}); + }); +}); + +const notFound = (specifier: string) => + Object.assign(new Error(`Cannot find '${specifier}'`), { code: "ERR_MODULE_NOT_FOUND" }); + +const dirImport = (specifier: string) => + Object.assign(new Error(`Directory import not allowed for '${specifier}'`), { + code: "ERR_UNSUPPORTED_DIR_IMPORT", + }); + +describe("resolve", () => { + test("retries with /index.ts for ERR_UNSUPPORTED_DIR_IMPORT on relative directory specifier", async () => { + const resolved = { url: "file:///path/to/models/index.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(dirImport("./models")) + .mockResolvedValueOnce(resolved); + const result = await resolve("./models", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./models/index.ts", {}); + }); + + test("retries with .ts extension for extensionless relative specifier on ERR_MODULE_NOT_FOUND", async () => { + const resolved = { url: "file:///path/to/foo.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("./foo")) + .mockResolvedValueOnce(resolved); + const result = await resolve("./foo", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./foo.ts", {}); + }); + + test("retries with .ts extension for .js specifier on ERR_MODULE_NOT_FOUND", async () => { + const resolved = { url: "file:///path/to/foo.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("./foo.js")) + .mockResolvedValueOnce(resolved); + const result = await resolve("./foo.js", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./foo.ts", {}); + }); + + test("does not append extensions when specifier already has a TS extension", async () => { + const nextResolve = vi.fn().mockRejectedValue(notFound("./foo.ts")); + await expect(resolve("./foo.ts", {}, nextResolve)).rejects.toMatchObject({ + code: "ERR_MODULE_NOT_FOUND", + }); + expect(nextResolve).toHaveBeenCalledTimes(1); + }); + + test("retries with .ts extension for extensionless specifier whose basename contains a dot", async () => { + const resolved = { url: "file:///path/to/permissions.generated.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("./permissions.generated")) + .mockResolvedValueOnce(resolved); + const result = await resolve("./permissions.generated", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./permissions.generated.ts", {}); + }); + + test("rethrows ERR_MODULE_NOT_FOUND for non-relative specifiers without retrying", async () => { + const nextResolve = vi.fn().mockRejectedValue(notFound("some-package")); + await expect(resolve("some-package", {}, nextResolve)).rejects.toMatchObject({ + code: "ERR_MODULE_NOT_FOUND", + }); + expect(nextResolve).toHaveBeenCalledTimes(1); + }); + + test("resolves non-relative specifier via tsconfig path alias", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@/tailordb/user")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@/tailordb/user", + { parentURL: "file:///alias-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("resolves non-relative specifier via tsconfig path alias without baseUrl", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-project-no-baseurl/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@/tailordb/user")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@/tailordb/user", + { parentURL: "file:///alias-project-no-baseurl/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("falls back to tsconfig directory when baseUrl is a non-string value", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: true, paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-project-bad-baseurl/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@/tailordb/user")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@/tailordb/user", + { parentURL: "file:///alias-project-bad-baseurl/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("ignores a paths alias whose target is not an array", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { paths: { "@/*": "./*" } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const nextResolve = vi.fn().mockRejectedValue(notFound("@/tailordb/user")); + await expect( + resolve( + "@/tailordb/user", + { parentURL: "file:///malformed-paths-project/tailor.config.ts" }, + nextResolve, + ), + ).rejects.toMatchObject({ code: "ERR_MODULE_NOT_FOUND" }); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("ignores non-string entries within a paths alias target array", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { paths: { "@/*": [123, "./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///malformed-paths-entry-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@/tailordb/user")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@/tailordb/user", + { parentURL: "file:///malformed-paths-entry-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("resolves tsconfig path alias when parentURL has tailorImportNonce query string", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@/tailordb/user")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@/tailordb/user", + { parentURL: "file:///alias-project/tailor.config.ts?tailorImportNonce=1" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("collects paths from same-directory extends (visited key tracks file path, not dir)", async () => { + const baseConfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } }, + }); + const rootConfig = JSON.stringify({ extends: "./tsconfig.base.json" }); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = String(path); + if (p.endsWith("tsconfig.base.json")) return baseConfig as unknown as string; + if (p.endsWith("tsconfig.json")) return rootConfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///extends-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@/tailordb/user")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@/tailordb/user", + { parentURL: "file:///extends-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("resolves child paths using the extended config's baseUrl directory, not the child config's directory", async () => { + const baseConfig = JSON.stringify({ + compilerOptions: { baseUrl: "shared-base" }, + }); + const rootConfig = JSON.stringify({ + extends: "./tsconfig.base.json", + compilerOptions: { paths: { "@app/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = String(path); + if (p.endsWith("tsconfig.base.json")) return baseConfig as unknown as string; + if (p.endsWith("tsconfig.json")) return rootConfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const nextResolve = vi.fn().mockRejectedValue(notFound("@app/tailordb/user")); + await expect( + resolve( + "@app/tailordb/user", + { parentURL: "file:///inherited-baseurl-project/tailor.config.ts" }, + nextResolve, + ), + ).rejects.toMatchObject({ code: "ERR_MODULE_NOT_FOUND" }); + expect(nextResolve).toHaveBeenCalledWith( + expect.stringContaining("inherited-baseurl-project/shared-base/tailordb/user.ts"), + expect.anything(), + ); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("resolves inherited paths using the child's own baseUrl override, not the defining config's baseUrl", async () => { + const baseConfig = JSON.stringify({ + compilerOptions: { baseUrl: "parent-base", paths: { "@shared/*": ["./*"] } }, + }); + const rootConfig = JSON.stringify({ + extends: "./tsconfig.base.json", + compilerOptions: { baseUrl: "child-base" }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = String(path); + if (p.endsWith("tsconfig.base.json")) return baseConfig as unknown as string; + if (p.endsWith("tsconfig.json")) return rootConfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const nextResolve = vi.fn().mockRejectedValue(notFound("@shared/tailordb/user")); + await expect( + resolve( + "@shared/tailordb/user", + { parentURL: "file:///override-baseurl-project/tailor.config.ts" }, + nextResolve, + ), + ).rejects.toMatchObject({ code: "ERR_MODULE_NOT_FOUND" }); + expect(nextResolve).toHaveBeenCalledWith( + expect.stringContaining("override-baseurl-project/child-base/tailordb/user.ts"), + expect.anything(), + ); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("replaces inherited paths instead of merging when child config defines its own paths", async () => { + const baseConfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@parent/*": ["./parent-src/*"] } }, + }); + const rootConfig = JSON.stringify({ + extends: "./tsconfig.base.json", + compilerOptions: { baseUrl: ".", paths: { "@child/*": ["./child-src/*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = String(path); + if (p.endsWith("tsconfig.base.json")) return baseConfig as unknown as string; + if (p.endsWith("tsconfig.json")) return rootConfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const nextResolve = vi.fn().mockRejectedValue(notFound("@parent/foo")); + await expect( + resolve( + "@parent/foo", + { parentURL: "file:///replace-paths-project/tailor.config.ts" }, + nextResolve, + ), + ).rejects.toMatchObject({ code: "ERR_MODULE_NOT_FOUND" }); + expect(nextResolve).toHaveBeenCalledTimes(1); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("keeps inherited paths when child config's own paths is malformed", async () => { + const baseConfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@parent/*": ["./parent-src/*"] } }, + }); + const rootConfig = JSON.stringify({ + extends: "./tsconfig.base.json", + compilerOptions: { paths: true }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = String(path); + if (p.endsWith("tsconfig.base.json")) return baseConfig as unknown as string; + if (p.endsWith("tsconfig.json")) return rootConfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///malformed-child-paths-project/parent-src/foo.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@parent/foo")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@parent/foo", + { parentURL: "file:///malformed-child-paths-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("prefers more specific wildcard alias over less specific", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { "@/*": ["./*"], "@foo/*": ["./foo-pkg/*"] }, + }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///specificity-project/foo-pkg/bar.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@foo/bar")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@foo/bar", + { parentURL: "file:///specificity-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenLastCalledWith( + expect.stringContaining("foo-pkg/bar"), + expect.anything(), + ); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("falls back to a less specific alias when a more specific alias's targets are all malformed", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { "@app/foo/*": [123], "@app/*": ["./*"] }, + }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///empty-target-fallback-project/foo/bar.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@app/foo/bar")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@app/foo/bar", + { parentURL: "file:///empty-target-fallback-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("does not append extensions when tsconfig path target already has a .ts extension", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/utils": ["./utils/index.ts"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///ext-project/utils/index.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@/utils")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@/utils", + { parentURL: "file:///ext-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledTimes(2); + expect(nextResolve).toHaveBeenLastCalledWith( + expect.stringContaining("utils/index.ts"), + expect.anything(), + ); + expect(nextResolve).not.toHaveBeenCalledWith( + expect.stringContaining("index.ts.ts"), + expect.anything(), + ); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); +}); + +describe("resolveSync", () => { + test("retries with /index.ts for ERR_UNSUPPORTED_DIR_IMPORT on relative directory specifier", () => { + const resolved = { url: "file:///path/to/models/index.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw dirImport("./models"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync("./models", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./models/index.ts", {}); + }); + + test("retries with .ts extension for extensionless relative specifier on ERR_MODULE_NOT_FOUND", () => { + const resolved = { url: "file:///path/to/foo.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("./foo"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync("./foo", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./foo.ts", {}); + }); + + test("retries with .ts extension for .js specifier on ERR_MODULE_NOT_FOUND", () => { + const resolved = { url: "file:///path/to/foo.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("./foo.js"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync("./foo.js", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./foo.ts", {}); + }); + + test("does not append extensions when specifier already has a TS extension", () => { + const nextResolve = vi.fn().mockImplementation(() => { + throw notFound("./foo.ts"); + }); + expect(() => resolveSync("./foo.ts", {}, nextResolve)).toThrow("Cannot find './foo.ts'"); + expect(nextResolve).toHaveBeenCalledTimes(1); + }); + + test("retries with .ts extension for extensionless specifier whose basename contains a dot", () => { + const resolved = { url: "file:///path/to/permissions.generated.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("./permissions.generated"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync("./permissions.generated", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./permissions.generated.ts", {}); + }); + + test("resolves non-relative specifier via tsconfig path alias", () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-sync-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("@/tailordb/user"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync( + "@/tailordb/user", + { parentURL: "file:///alias-sync-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("resolves tsconfig path alias when parentURL has tailorImportNonce query string", () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-sync-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("@/tailordb/user"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync( + "@/tailordb/user", + { parentURL: "file:///alias-sync-project/tailor.config.ts?tailorImportNonce=1" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("resolves non-relative specifier via tsconfig path alias without baseUrl", () => { + const tsconfig = JSON.stringify({ + compilerOptions: { paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-sync-project-no-baseurl/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("@/tailordb/user"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync( + "@/tailordb/user", + { parentURL: "file:///alias-sync-project-no-baseurl/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("falls back to tsconfig directory when baseUrl is a non-string value", () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: true, paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-sync-project-bad-baseurl/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("@/tailordb/user"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync( + "@/tailordb/user", + { parentURL: "file:///alias-sync-project-bad-baseurl/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("ignores a paths alias whose target is not an array", () => { + const tsconfig = JSON.stringify({ + compilerOptions: { paths: { "@/*": "./*" } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const nextResolve = vi.fn().mockImplementation(() => { + throw notFound("@/tailordb/user"); + }); + expect(() => + resolveSync( + "@/tailordb/user", + { parentURL: "file:///malformed-paths-sync-project/tailor.config.ts" }, + nextResolve, + ), + ).toThrow("Cannot find '@/tailordb/user'"); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("ignores non-string entries within a paths alias target array", () => { + const tsconfig = JSON.stringify({ + compilerOptions: { paths: { "@/*": [123, "./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///malformed-paths-entry-sync-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("@/tailordb/user"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync( + "@/tailordb/user", + { parentURL: "file:///malformed-paths-entry-sync-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("resolves child paths using the extended config's baseUrl directory, not the child config's directory", () => { + const baseConfig = JSON.stringify({ + compilerOptions: { baseUrl: "shared-base" }, + }); + const rootConfig = JSON.stringify({ + extends: "./tsconfig.base.json", + compilerOptions: { paths: { "@app/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = String(path); + if (p.endsWith("tsconfig.base.json")) return baseConfig as unknown as string; + if (p.endsWith("tsconfig.json")) return rootConfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const nextResolve = vi.fn().mockImplementation(() => { + throw notFound("@app/tailordb/user"); + }); + expect(() => + resolveSync( + "@app/tailordb/user", + { parentURL: "file:///inherited-baseurl-sync-project/tailor.config.ts" }, + nextResolve, + ), + ).toThrow("Cannot find '@app/tailordb/user'"); + expect(nextResolve).toHaveBeenCalledWith( + expect.stringContaining("inherited-baseurl-sync-project/shared-base/tailordb/user.ts"), + expect.anything(), + ); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("resolves inherited paths using the child's own baseUrl override, not the defining config's baseUrl", () => { + const baseConfig = JSON.stringify({ + compilerOptions: { baseUrl: "parent-base", paths: { "@shared/*": ["./*"] } }, + }); + const rootConfig = JSON.stringify({ + extends: "./tsconfig.base.json", + compilerOptions: { baseUrl: "child-base" }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = String(path); + if (p.endsWith("tsconfig.base.json")) return baseConfig as unknown as string; + if (p.endsWith("tsconfig.json")) return rootConfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const nextResolve = vi.fn().mockImplementation(() => { + throw notFound("@shared/tailordb/user"); + }); + expect(() => + resolveSync( + "@shared/tailordb/user", + { parentURL: "file:///override-baseurl-sync-project/tailor.config.ts" }, + nextResolve, + ), + ).toThrow("Cannot find '@shared/tailordb/user'"); + expect(nextResolve).toHaveBeenCalledWith( + expect.stringContaining("override-baseurl-sync-project/child-base/tailordb/user.ts"), + expect.anything(), + ); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("replaces inherited paths instead of merging when child config defines its own paths", () => { + const baseConfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@parent/*": ["./parent-src/*"] } }, + }); + const rootConfig = JSON.stringify({ + extends: "./tsconfig.base.json", + compilerOptions: { baseUrl: ".", paths: { "@child/*": ["./child-src/*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = String(path); + if (p.endsWith("tsconfig.base.json")) return baseConfig as unknown as string; + if (p.endsWith("tsconfig.json")) return rootConfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const nextResolve = vi.fn().mockImplementation(() => { + throw notFound("@parent/foo"); + }); + expect(() => + resolveSync( + "@parent/foo", + { parentURL: "file:///replace-paths-sync-project/tailor.config.ts" }, + nextResolve, + ), + ).toThrow("Cannot find '@parent/foo'"); + expect(nextResolve).toHaveBeenCalledTimes(1); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("keeps inherited paths when child config's own paths is malformed", () => { + const baseConfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@parent/*": ["./parent-src/*"] } }, + }); + const rootConfig = JSON.stringify({ + extends: "./tsconfig.base.json", + compilerOptions: { paths: true }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = String(path); + if (p.endsWith("tsconfig.base.json")) return baseConfig as unknown as string; + if (p.endsWith("tsconfig.json")) return rootConfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///malformed-child-paths-sync-project/parent-src/foo.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("@parent/foo"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync( + "@parent/foo", + { parentURL: "file:///malformed-child-paths-sync-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("falls back to a less specific alias when a more specific alias's targets are all malformed", () => { + const tsconfig = JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { "@app/foo/*": [123], "@app/*": ["./*"] }, + }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///empty-target-fallback-sync-project/foo/bar.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("@app/foo/bar"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync( + "@app/foo/bar", + { parentURL: "file:///empty-target-fallback-sync-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); +}); diff --git a/packages/sdk/src/cli/tsconfig-paths-hook.d.mts b/packages/sdk/src/cli/tsconfig-paths-hook.d.mts deleted file mode 100644 index 7824c4196..000000000 --- a/packages/sdk/src/cli/tsconfig-paths-hook.d.mts +++ /dev/null @@ -1 +0,0 @@ -export declare function resolve(...args: unknown[]): Promise; diff --git a/packages/sdk/src/cli/tsconfig-paths-hook.mjs b/packages/sdk/src/cli/tsconfig-paths-hook.mjs deleted file mode 100644 index 7eef544cf..000000000 --- a/packages/sdk/src/cli/tsconfig-paths-hook.mjs +++ /dev/null @@ -1,153 +0,0 @@ -import { dirname } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { createPathsMatcher, getTsconfig } from "get-tsconfig"; - -// Resolve-only Node.js module hook: fixes tsconfig `paths` alias resolution -// for dynamically-imported user files (resolvers, executors, workflows, -// TailorDB types). tsx's own tsconfig-paths support is scoped to the -// tsconfig discovered relative to where tsx itself was registered, so an -// alias declared in a project-local tsconfig.json can fail to resolve when -// the imported file lives in a different directory. This hook activates -// only when tsx's own resolution throws (a fallback, not an override): it -// then re-derives the effective `paths` matcher from the importing file's -// own directory, so the fallback resolves against each file's own -// project's tsconfig regardless of process cwd or which other -// tsconfig-bearing projects have already been loaded in the same process. -// -// Known limitation: if tsx's cwd-scoped tsconfig happens to define the -// same alias pattern as the importing file's own tsconfig (e.g. both share -// a `@/*` convention) and a file exists at the guessed target, tsx's own -// resolution succeeds — incorrectly — before this fallback ever runs. -// Winning that race would require this hook to run ahead of tsx's own -// resolution, which was investigated and found to depend on undocumented, -// inconsistent Node.js module-hook composition behavior between tsx's two -// internal registration APIs; not attempted here. -// -// Registered via module.register() (not module.registerHooks()): chaining -// another register()-based loader after tsx's composes correctly regardless -// of which of the two internal registration APIs tsx itself picks (it -// varies by Node.js version). -// -// TypeScript transformation itself is left to tsx's already-registered load -// hook; this only supplies the resolve fallback tsx's cwd-scoped resolver -// misses. - -const TS_EXTENSIONS = [".ts", ".tsx", ".mts"]; -const JS_EXTENSIONS = [".js", ".jsx", ".mjs"]; - -// A specifier ending in one of these already names a JS-style output -// extension (the pattern TypeScript's NodeNext/bundler resolution expects -// for relative-looking specifiers): it must map to the corresponding TS -// source extension, not be treated as extensionless and have a TS -// extension appended after it (which would guess e.g. `utils.js.ts`). -const JS_TO_TS_EXT = new Map([ - [".js", ".ts"], - [".jsx", ".tsx"], - [".mjs", ".mts"], -]); - -// Cached per directory for the lifetime of this process. `generate --watch` -// restarts its own child process on config/service-file changes (clearing -// this cache along with it), but it does not currently watch tsconfig.json -// itself, so edits to `paths` there won't be picked up until the next -// restart — a pre-existing limitation of watch mode, not new here. -const tsconfigCache = new Map(); -const matcherCache = new Map(); - -function getResolutionContext(startDir) { - if (matcherCache.has(startDir)) return matcherCache.get(startDir); - - const tsconfig = getTsconfig(startDir, "tsconfig.json", tsconfigCache); - const matcher = tsconfig ? createPathsMatcher(tsconfig) : null; - const result = matcher - ? { matcher, allowJs: tsconfig.config.compilerOptions?.allowJs ?? false } - : null; - - matcherCache.set(startDir, result); - return result; -} - -async function tryResolveWithExtensions(base, context, nextResolve, allowJs) { - for (const [jsExt, tsExt] of JS_TO_TS_EXT) { - if (base.endsWith(jsExt)) { - try { - return await nextResolve(base.slice(0, -jsExt.length) + tsExt, context); - } catch (e) { - if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; - } - return null; - } - } - - if (!TS_EXTENSIONS.some((ext) => base.endsWith(ext))) { - // TypeScript resolves a file at this path before falling back to a - // directory's index file, so file extensions are tried (across every - // suffix-less candidate) before any `/index` candidate. - const extensions = allowJs ? [...TS_EXTENSIONS, ...JS_EXTENSIONS] : TS_EXTENSIONS; - for (const suffix of ["", "/index"]) { - for (const ext of extensions) { - try { - return await nextResolve(base + suffix + ext, context); - } catch (e) { - if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; - } - } - } - } - try { - return await nextResolve(base, context); - } catch (e) { - const code = e?.code; - if (code !== "ERR_MODULE_NOT_FOUND" && code !== "ERR_UNSUPPORTED_DIR_IMPORT") throw e; - } - return null; -} - -// Hook for module.register(). Only handles the case default resolution -// (including tsx's own tsconfig-paths support) already failed on: a -// non-relative bare specifier that maps to a tsconfig `paths` alias declared -// by a tsconfig.json above the importing file's own directory. Relative -// imports and anything tsx/Node already resolves are left alone. -// -// A `#`-prefixed specifier (Node's subpath imports syntax, also usable as a -// tsconfig `paths` alias pattern) fails with ERR_PACKAGE_IMPORT_NOT_DEFINED -// or ERR_INVALID_MODULE_SPECIFIER rather than ERR_MODULE_NOT_FOUND, so those -// codes are treated as recoverable for `#`-prefixed specifiers only — -// narrow enough to avoid masking genuinely malformed specifiers elsewhere. -export async function resolve(specifier, context, nextResolve) { - try { - return await nextResolve(specifier, context); - } catch (err) { - const code = err?.code; - const isSubpathImport = specifier.startsWith("#"); - const recoverable = - code === "ERR_MODULE_NOT_FOUND" || - code === "ERR_UNSUPPORTED_DIR_IMPORT" || - (isSubpathImport && - (code === "ERR_PACKAGE_IMPORT_NOT_DEFINED" || code === "ERR_INVALID_MODULE_SPECIFIER")); - if (!recoverable) throw err; - if (specifier.startsWith(".") || specifier.startsWith("/")) throw err; - if (!context.parentURL?.startsWith("file://")) throw err; - - const parentParsed = new URL(context.parentURL); - parentParsed.search = ""; - parentParsed.hash = ""; - const parentDir = dirname(fileURLToPath(parentParsed)); - - const resolutionContext = getResolutionContext(parentDir); - if (!resolutionContext) throw err; - const candidates = resolutionContext.matcher(specifier); - if (!candidates || candidates.length === 0) throw err; - - for (const candidatePath of candidates) { - const result = await tryResolveWithExtensions( - pathToFileURL(candidatePath).href, - context, - nextResolve, - resolutionContext.allowJs, - ); - if (result) return result; - } - throw err; - } -} diff --git a/packages/sdk/src/cli/tsconfig-paths-hook.test.ts b/packages/sdk/src/cli/tsconfig-paths-hook.test.ts deleted file mode 100644 index 6191f8658..000000000 --- a/packages/sdk/src/cli/tsconfig-paths-hook.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { pathToFileURL } from "node:url"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { resolve } from "./tsconfig-paths-hook.mjs"; - -const notFound = (specifier: string) => - Object.assign(new Error(`Cannot find '${specifier}'`), { code: "ERR_MODULE_NOT_FOUND" }); - -const dirImport = (specifier: string) => - Object.assign(new Error(`Directory import '${specifier}' is not supported`), { - code: "ERR_UNSUPPORTED_DIR_IMPORT", - }); - -const packageImportNotDefined = (specifier: string) => - Object.assign(new Error(`Package import specifier "${specifier}" is not defined`), { - code: "ERR_PACKAGE_IMPORT_NOT_DEFINED", - }); - -function makeProject(tsconfig: object): { dir: string; parentURL: string } { - const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tsconfig-paths-hook-test-"))); - fs.writeFileSync(path.join(dir, "tsconfig.json"), JSON.stringify(tsconfig)); - return { dir, parentURL: pathToFileURL(path.join(dir, "resolver.ts")).href }; -} - -describe("resolve", () => { - const dirs: string[] = []; - - afterEach(() => { - for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); - }); - - test("delegates to nextResolve first and returns its result on success", async () => { - const resolved = { url: "file:///node_modules/some-package/index.js" }; - const nextResolve = vi.fn().mockResolvedValue(resolved); - const result = await resolve("some-package", {}, nextResolve); - expect(result).toEqual(resolved); - }); - - test("known limitation: does not correct a specifier that a coincidentally-matching alias in a different tsconfig already resolved successfully", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } }, - }); - dirs.push(dir); - // Simulates tsx's own tsconfig-paths support (scoped to a different, - // cwd-discovered tsconfig that happens to share the same alias pattern) - // succeeding against the wrong target before this hook ever gets a - // chance to run — it only activates on ERR_MODULE_NOT_FOUND. - const wrongResolution = { url: "file:///unrelated-project/src/utils.ts" }; - const nextResolve = vi.fn().mockResolvedValue(wrongResolution); - const result = await resolve("@/utils", { parentURL }, nextResolve); - expect(result).toEqual(wrongResolution); - }); - - test("rethrows ERR_MODULE_NOT_FOUND for non-relative specifiers with no matching tsconfig", async () => { - const { dir, parentURL } = makeProject({ compilerOptions: { baseUrl: ".", paths: {} } }); - dirs.push(dir); - const nextResolve = vi.fn().mockRejectedValue(notFound("some-package")); - await expect(resolve("some-package", { parentURL }, nextResolve)).rejects.toThrow( - "Cannot find 'some-package'", - ); - }); - - test("does not intercept relative specifiers, leaving them to tsx/Node", async () => { - const nextResolve = vi.fn().mockRejectedValue(notFound("./foo")); - await expect(resolve("./foo", {}, nextResolve)).rejects.toThrow("Cannot find './foo'"); - expect(nextResolve).toHaveBeenCalledTimes(1); - }); - - test("resolves non-relative specifier via tsconfig path alias from the importing file's own directory", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } }, - }); - dirs.push(dir); - const resolved = { url: "file:///resolved/utils.ts" }; - const expectedCandidate = pathToFileURL(path.join(dir, "src", "utils")).href; - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - if (specifier === expectedCandidate + ".ts") return resolved; - throw notFound(specifier); - }); - const result = await resolve("@/utils", { parentURL }, nextResolve); - expect(result).toEqual(resolved); - }); - - test("resolves each importing file's alias against its own project, not a previously loaded one", async () => { - const projectA = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "@/*": ["./a-src/*"] } }, - }); - const projectB = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "@/*": ["./b-src/*"] } }, - }); - dirs.push(projectA.dir, projectB.dir); - - const resolvedA = { url: "file:///a-src/utils.ts" }; - const candidateA = pathToFileURL(path.join(projectA.dir, "a-src", "utils")).href; - const nextResolveA = vi.fn().mockImplementation((specifier: string) => { - if (specifier === candidateA + ".ts") return resolvedA; - throw notFound(specifier); - }); - await expect( - resolve("@/utils", { parentURL: projectA.parentURL }, nextResolveA), - ).resolves.toEqual(resolvedA); - - const resolvedB = { url: "file:///b-src/utils.ts" }; - const candidateB = pathToFileURL(path.join(projectB.dir, "b-src", "utils")).href; - const nextResolveB = vi.fn().mockImplementation((specifier: string) => { - if (specifier === candidateB + ".ts") return resolvedB; - throw notFound(specifier); - }); - await expect( - resolve("@/utils", { parentURL: projectB.parentURL }, nextResolveB), - ).resolves.toEqual(resolvedB); - }); - - test("strips search/hash from parentURL before deriving the importing file's directory", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } }, - }); - dirs.push(dir); - const resolved = { url: "file:///resolved/utils.ts" }; - const expectedCandidate = pathToFileURL(path.join(dir, "src", "utils")).href; - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - if (specifier === expectedCandidate + ".ts") return resolved; - throw notFound(specifier); - }); - const result = await resolve( - "@/utils", - { parentURL: `${parentURL}?tailorImportNonce=1` }, - nextResolve, - ); - expect(result).toEqual(resolved); - }); - - test("resolves paths inherited through an extends chain", async () => { - const dir = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), "tsconfig-paths-hook-test-")), - ); - dirs.push(dir); - fs.writeFileSync( - path.join(dir, "tsconfig.base.json"), - JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } } }), - ); - fs.writeFileSync( - path.join(dir, "tsconfig.json"), - JSON.stringify({ extends: "./tsconfig.base.json" }), - ); - const parentURL = pathToFileURL(path.join(dir, "resolver.ts")).href; - - const resolved = { url: "file:///resolved/utils.ts" }; - const expectedCandidate = pathToFileURL(path.join(dir, "src", "utils")).href; - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - if (specifier === expectedCandidate + ".ts") return resolved; - throw notFound(specifier); - }); - const result = await resolve("@/utils", { parentURL }, nextResolve); - expect(result).toEqual(resolved); - }); - - test("prefers the more specific wildcard alias", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { - baseUrl: ".", - paths: { "@/*": ["./src/*"], "@/foo/*": ["./foo-pkg/*"] }, - }, - }); - dirs.push(dir); - const resolved = { url: "file:///resolved/bar.ts" }; - const expectedCandidate = pathToFileURL(path.join(dir, "foo-pkg", "bar")).href; - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - if (specifier === expectedCandidate + ".ts") return resolved; - throw notFound(specifier); - }); - const result = await resolve("@/foo/bar", { parentURL }, nextResolve); - expect(result).toEqual(resolved); - }); - - test("resolves a directory-style alias target via its index file", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } }, - }); - dirs.push(dir); - const resolved = { url: "file:///resolved/models/index.ts" }; - const expectedCandidate = pathToFileURL(path.join(dir, "src", "models")).href; - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - if (specifier === expectedCandidate + "/index.ts") return resolved; - throw notFound(specifier); - }); - const result = await resolve("@/models", { parentURL }, nextResolve); - expect(result).toEqual(resolved); - }); - - test("attempts the tsconfig paths fallback when the initial resolution fails with ERR_UNSUPPORTED_DIR_IMPORT", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } }, - }); - dirs.push(dir); - const resolved = { url: "file:///resolved/models/index.ts" }; - const expectedCandidate = pathToFileURL(path.join(dir, "src", "models")).href; - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - if (specifier === expectedCandidate + "/index.ts") return resolved; - if (specifier === "@/models") throw dirImport(specifier); - throw notFound(specifier); - }); - const result = await resolve("@/models", { parentURL }, nextResolve); - expect(result).toEqual(resolved); - }); - - test("prefers a sibling file over a same-name directory's index, matching TypeScript's own resolution order", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } }, - }); - dirs.push(dir); - const siblingFile = { url: "file:///resolved/foo.tsx" }; - const directoryIndex = { url: "file:///resolved/foo/index.ts" }; - const expectedCandidate = pathToFileURL(path.join(dir, "src", "foo")).href; - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - if (specifier === expectedCandidate + ".tsx") return siblingFile; - if (specifier === expectedCandidate + "/index.ts") return directoryIndex; - throw notFound(specifier); - }); - const result = await resolve("@/foo", { parentURL }, nextResolve); - expect(result).toEqual(siblingFile); - }); - - test("maps a .js specifier extension to the corresponding TS source extension instead of appending a TS extension", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } }, - }); - dirs.push(dir); - const resolved = { url: "file:///resolved/utils.ts" }; - const expectedCandidate = pathToFileURL(path.join(dir, "src", "utils")).href; - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - if (specifier === expectedCandidate + ".ts") return resolved; - throw notFound(specifier); - }); - const result = await resolve("@/utils.js", { parentURL }, nextResolve); - expect(result).toEqual(resolved); - expect(nextResolve).not.toHaveBeenCalledWith( - expect.stringContaining("utils.js.ts"), - expect.anything(), - ); - }); - - test("probes an allowed .js source for an extensionless alias when allowJs is set", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { baseUrl: ".", allowJs: true, paths: { "@/*": ["./src/*"] } }, - }); - dirs.push(dir); - const resolved = { url: "file:///resolved/utils.js" }; - const expectedCandidate = pathToFileURL(path.join(dir, "src", "utils")).href; - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - if (specifier === expectedCandidate + ".js") return resolved; - throw notFound(specifier); - }); - const result = await resolve("@/utils", { parentURL }, nextResolve); - expect(result).toEqual(resolved); - }); - - test("does not probe .js sources for an extensionless alias when allowJs is unset", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } }, - }); - dirs.push(dir); - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - throw notFound(specifier); - }); - await expect(resolve("@/utils", { parentURL }, nextResolve)).rejects.toThrow( - "Cannot find '@/utils'", - ); - expect(nextResolve).not.toHaveBeenCalledWith(expect.stringContaining(".js"), expect.anything()); - }); - - test("attempts the tsconfig paths fallback for a subpath-imports specifier failing with ERR_PACKAGE_IMPORT_NOT_DEFINED", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "#/*": ["./src/*"] } }, - }); - dirs.push(dir); - const resolved = { url: "file:///resolved/utils.ts" }; - const expectedCandidate = pathToFileURL(path.join(dir, "src", "utils")).href; - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - if (specifier === expectedCandidate + ".ts") return resolved; - if (specifier === "#/utils") throw packageImportNotDefined(specifier); - throw notFound(specifier); - }); - const result = await resolve("#/utils", { parentURL }, nextResolve); - expect(result).toEqual(resolved); - }); - - test("does not treat ERR_PACKAGE_IMPORT_NOT_DEFINED as recoverable for a non-subpath-imports specifier", async () => { - const { dir, parentURL } = makeProject({ - compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } }, - }); - dirs.push(dir); - const nextResolve = vi.fn().mockImplementation((specifier: string) => { - throw packageImportNotDefined(specifier); - }); - await expect(resolve("@/utils", { parentURL }, nextResolve)).rejects.toMatchObject({ - code: "ERR_PACKAGE_IMPORT_NOT_DEFINED", - }); - expect(nextResolve).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/sdk/src/configure/config/index.test.ts b/packages/sdk/src/configure/config/index.test.ts index 55fb02492..1c68dad10 100644 --- a/packages/sdk/src/configure/config/index.test.ts +++ b/packages/sdk/src/configure/config/index.test.ts @@ -14,7 +14,7 @@ describe("defineConfig", () => { test("accepts logLevel from an environment variable fallback", () => { defineConfig({ name: "my-app", - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", }); }); }); diff --git a/packages/sdk/src/configure/config/index.ts b/packages/sdk/src/configure/config/index.ts index d003101bc..290a850a2 100644 --- a/packages/sdk/src/configure/config/index.ts +++ b/packages/sdk/src/configure/config/index.ts @@ -1,5 +1,5 @@ import type { AppConfig } from "#/configure/config/types"; -import type { GeneratorConfig, Plugin } from "#/plugin/types"; +import type { Plugin } from "#/plugin/types"; /** * Define a Tailor SDK application configuration with shallow exactness. @@ -16,17 +16,6 @@ export function defineConfig< return config; } -/** - * Define generators to be used with the Tailor SDK. - * @deprecated Use definePlugins() with generation hooks (onTypeLoaded, generate, etc.) instead. - * @param configs - Generator configurations - * @returns Generator configurations as given - */ -/* @__NO_SIDE_EFFECTS__ */ -export function defineGenerators(...configs: GeneratorConfig[]) { - return configs; -} - /** * Define plugins to be used with the Tailor SDK. * Plugins can generate additional types, resolvers, and executors diff --git a/packages/sdk/src/configure/index.ts b/packages/sdk/src/configure/index.ts index 35c0bc192..1ed2e3525 100644 --- a/packages/sdk/src/configure/index.ts +++ b/packages/sdk/src/configure/index.ts @@ -17,13 +17,11 @@ export namespace t { export { type TailorField } from "#/configure/types/type"; export { - type TailorUser, - type TailorInvoker, - type AttributeMap, + type TailorPrincipal, + type Attributes, type AttributeList, type Env, } from "#/runtime/types"; -export { unauthenticatedTailorUser } from "#/configure/user"; export { type MachineUserNameRegistry, type MachineUserName } from "#/configure/types/machine-user"; export { type IdpNameRegistry, type IdpName } from "#/configure/types/idp-name"; export { @@ -34,7 +32,7 @@ export { type AIGatewayNameRegistry, type AIGatewayName } from "#/configure/type export * from "#/configure/services/index"; -export { defineConfig, defineGenerators, definePlugins } from "#/configure/config/index"; +export { defineConfig, definePlugins } from "#/configure/config/index"; // Plugin types for custom plugin development export type { diff --git a/packages/sdk/src/configure/services/auth/index.test.ts b/packages/sdk/src/configure/services/auth/index.test.ts index 6b52db7c8..c14daff6c 100644 --- a/packages/sdk/src/configure/services/auth/index.test.ts +++ b/packages/sdk/src/configure/services/auth/index.test.ts @@ -3,16 +3,15 @@ import { randomUUID } from "node:crypto"; import { describe, expect, test, expectTypeOf } from "vitest"; import { t } from "#/configure/types/type"; import { db } from "../tailordb/schema"; -import { defineAuth, type AuthInvoker } from "./index"; +import { defineAuth } from "./index"; import type { BeforeLoginHook, BeforeLoginHookArgs, FederatedIdentity, } from "#/configure/services/auth/types"; -import type { AuthInvoker as ProtoAuthInvoker } from "@tailor-platform/tailor-proto/auth_resource_pb"; import type { JsonObject } from "type-fest"; -const userType = db.type("User", { +const userType = db.table("User", { email: db.string().unique(), role: db.string(), isActive: db.bool(), @@ -20,7 +19,7 @@ const userType = db.type("User", { externalId: db.uuid(), }); -type AttributeMap = { +type Attributes = { role: true; isActive: true; tags: true; @@ -29,7 +28,7 @@ type AttributeMap = { type AttributeList = ["externalId"]; -const attributeMapConfig: AttributeMap = { +const attributeMapConfig: Attributes = { role: true, isActive: true, tags: true, @@ -68,24 +67,6 @@ describe("defineAuth", () => { expect(authConfig.machineUsers?.admin.attributes?.role).toBe("ADMIN"); }); - test("creates auth configuration with invoker method", () => { - const authConfig = defineAuth("test-service", { - userProfile: basicUserProfile, - machineUsers: { - admin: {}, - worker: {}, - }, - }); - - const invoker = authConfig.invoker("admin"); - expect(invoker.namespace).toBe("test-service"); - expect(invoker.machineUserName).toBe("admin"); - - const workerInvoker = authConfig.invoker("worker"); - expect(workerInvoker.namespace).toBe("test-service"); - expect(workerInvoker.machineUserName).toBe("worker"); - }); - test("creates minimal auth configuration", () => { const authConfig = defineAuth("minimal", { userProfile: basicUserProfile, @@ -94,6 +75,8 @@ describe("defineAuth", () => { expect(authConfig.name).toBe("minimal"); expect(authConfig.userProfile.type).toBe(userType); expect(authConfig.machineUsers).toBeUndefined(); + expect(authConfig).not.toHaveProperty("getConnectionToken"); + expectTypeOf(authConfig).not.toHaveProperty("getConnectionToken"); }); test("creates auth configuration with machineUsers only", () => { @@ -155,7 +138,7 @@ describe("defineAuth", () => { }); test("mirrors user field optionality in userProfile-derived machine user attributes", () => { - const mirrorUserType = db.type("MirrorUser", { + const mirrorUserType = db.table("MirrorUser", { email: db.string().unique(), role: db.string(), nickname: db.string({ optional: true }), @@ -299,8 +282,6 @@ describe("defineAuth", () => { }); expect(authConfig.hooks!.beforeLogin!.invoker).toBe("admin"); - // invoker should not narrow MachineUserNames — both machine users must remain valid - expectTypeOf(authConfig.invoker).parameter(0).toEqualTypeOf<"admin" | "worker">(); }); test("typed claims expose federated_identity while keeping arbitrary claims", () => { @@ -327,64 +308,4 @@ describe("defineAuth", () => { expect(authConfig.hooks).toBeUndefined(); }); }); - - describe("AuthInvoker type compatibility with tailor-proto", () => { - test("AuthInvoker has namespace field compatible with proto", () => { - // Verify the field name matches tailor.v1.AuthInvoker - type HasNamespace = AuthInvoker extends { namespace: string } ? true : false; - expectTypeOf().toEqualTypeOf(); - - // Verify proto type has the same field - type ProtoHasNamespace = ProtoAuthInvoker extends { namespace: string } ? true : false; - expectTypeOf().toEqualTypeOf(); - }); - - test("AuthInvoker has machineUserName field compatible with proto", () => { - // Verify the field name matches tailor.v1.AuthInvoker - type HasMachineUserName = - AuthInvoker extends { - machineUserName: string; - } - ? true - : false; - expectTypeOf().toEqualTypeOf(); - - // Verify proto type has the same field - type ProtoHasMachineUserName = ProtoAuthInvoker extends { - machineUserName: string; - } - ? true - : false; - expectTypeOf().toEqualTypeOf(); - }); - - test("AuthInvoker is assignable to proto AuthInvoker fields", () => { - // This ensures that our AuthInvoker can be used where proto AuthInvoker is expected - // (checking the common properties) - type IsCompatible = - AuthInvoker extends Pick - ? true - : false; - expectTypeOf().toEqualTypeOf(); - }); - - test("invoker() returns AuthInvoker compatible object", () => { - const authConfig = defineAuth("test-auth", { - userProfile: basicUserProfile, - machineUsers: { - admin: {}, - }, - }); - - const invoker = authConfig.invoker("admin"); - - // Verify at runtime that the object has the correct field names - expect(invoker).toHaveProperty("namespace"); - expect(invoker).toHaveProperty("machineUserName"); - - // Verify it does NOT have the old field names - expect(invoker).not.toHaveProperty("authName"); - expect(invoker).not.toHaveProperty("machineUser"); - }); - }); }); diff --git a/packages/sdk/src/configure/services/auth/index.ts b/packages/sdk/src/configure/services/auth/index.ts index 4a716c7bf..87bca9d67 100644 --- a/packages/sdk/src/configure/services/auth/index.ts +++ b/packages/sdk/src/configure/services/auth/index.ts @@ -1,11 +1,10 @@ import { type TailorDBInstance } from "../tailordb/schema"; import type { - AuthConnectionTokenResult, AuthDefinitionBrand, AuthServiceInput, DefinedAuth, UserAttributeListKey, - UserAttributeMap, + UserAttributes, } from "#/configure/services/auth/types"; import type { DefinedFieldMetadata, @@ -13,7 +12,6 @@ import type { TailorFieldType, TailorField, } from "#/configure/types/field.types"; -import type { AuthInvoker as ParserAuthInvoker } from "#/types/auth.generated"; type MachineUserAttributeFields = Record< string, @@ -21,21 +19,21 @@ type MachineUserAttributeFields = Record< >; type PlaceholderUser = TailorDBInstance, Record>; -type PlaceholderAttributeMap = UserAttributeMap; +type PlaceholderAttributes = UserAttributes; type PlaceholderAttributeList = UserAttributeListKey[]; type UserProfileAuthInput< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap, + Attributes extends UserAttributes, AttributeList extends UserAttributeListKey[], MachineUserNames extends string, ConnectionNames extends string = string, > = Omit< - AuthServiceInput, + AuthServiceInput, "userProfile" | "machineUserAttributes" > & { userProfile: NonNullable< - AuthServiceInput["userProfile"] + AuthServiceInput["userProfile"] >; machineUserAttributes?: never; }; @@ -47,7 +45,7 @@ type MachineUserOnlyAuthInput< > = Omit< AuthServiceInput< PlaceholderUser, - PlaceholderAttributeMap, + PlaceholderAttributes, PlaceholderAttributeList, MachineUserNames, MachineUserAttributes, @@ -91,7 +89,7 @@ export type { UsernameFieldKey, UserAttributeKey, UserAttributeListKey, - UserAttributeMap, + UserAttributes, AuthConnectionTokenResult, AuthServiceInput, AuthConfig, @@ -100,23 +98,13 @@ export type { DefinedAuth, } from "#/configure/services/auth/types"; -/** - * Invoker type compatible with tailor.v1.AuthInvoker - * - namespace: auth service name - * - machineUserName: machine user name - */ -export type AuthInvoker = Omit & { - machineUserName: M; -}; - /** * Define an auth service for the Tailor SDK. * @template Name * @template User - * @template AttributeMap + * @template Attributes * @template AttributeList * @template MachineUserNames - * @template M * @param name - Auth service name * @param config - Auth service configuration * @returns Defined auth service @@ -124,23 +112,16 @@ export type AuthInvoker = Omit, + const Attributes extends UserAttributes, const AttributeList extends UserAttributeListKey[], const MachineUserNames extends string, const ConnectionNames extends string = string, >( name: Name, - config: UserProfileAuthInput< - User, - AttributeMap, - AttributeList, - MachineUserNames, - ConnectionNames - >, + config: UserProfileAuthInput, ): DefinedAuth< Name, - UserProfileAuthInput, - MachineUserNames + UserProfileAuthInput >; export function defineAuth< const Name extends string, @@ -152,14 +133,13 @@ export function defineAuth< config: MachineUserOnlyAuthInput, ): DefinedAuth< Name, - MachineUserOnlyAuthInput, - MachineUserNames + MachineUserOnlyAuthInput >; /* @__NO_SIDE_EFFECTS__ */ export function defineAuth< const Name extends string, const User extends TailorDBInstance, - const AttributeMap extends UserAttributeMap, + const Attributes extends UserAttributes, const AttributeList extends UserAttributeListKey[], const MachineUserAttributes extends MachineUserAttributeFields, const MachineUserNames extends string, @@ -167,25 +147,17 @@ export function defineAuth< >( name: Name, config: - | UserProfileAuthInput + | UserProfileAuthInput | MachineUserOnlyAuthInput, ) { const result = { ...config, name, - invoker(machineUser: M) { - return { namespace: name, machineUserName: machineUser } as const; - }, - getConnectionToken(connectionName: C): Promise { - return tailor.authconnection.getConnectionToken(connectionName); - }, } as const satisfies ( - | UserProfileAuthInput - | MachineUserOnlyAuthInput + | UserProfileAuthInput + | MachineUserOnlyAuthInput ) & { name: string; - invoker(machineUser: M): AuthInvoker; - getConnectionToken(connectionName: C): Promise; }; return result as typeof result & AuthDefinitionBrand; diff --git a/packages/sdk/src/configure/services/auth/types.ts b/packages/sdk/src/configure/services/auth/types.ts index 013c7b3fc..52984330d 100644 --- a/packages/sdk/src/configure/services/auth/types.ts +++ b/packages/sdk/src/configure/services/auth/types.ts @@ -12,7 +12,6 @@ import type { TailorEnv } from "#/runtime/types"; // references, importable type-only from any layer. import type { AuthConnectionConfig } from "#/types/auth-connection.generated"; import type { - AuthInvoker, IdProvider as IdProviderConfig, OAuth2Client, OAuth2ClientInput, @@ -27,9 +26,21 @@ import type { IsAny, JsonObject, JsonValue } from "type-fest"; export type OAuth2ClientGrantType = OAuth2Client["grantTypes"][number]; export type SCIMAttributeType = SCIMAttribute["type"]; -export type AuthInvokerWithName = Omit & { - machineUserName: M; -}; +// Interface for module augmentation +// Users can extend via: declare module "@tailor-platform/sdk" { interface MachineUserNameRegistry { ... } } +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface MachineUserNameRegistry {} + +/** + * Machine user name. + * + * When `tailor.d.ts` is generated (via `tailor deploy`/`generate`), this is narrowed + * to the union of defined machine user names. When no machine users are registered yet, + * falls back to `string` to avoid blocking editing before the first generate run. + */ +export type MachineUserName = keyof MachineUserNameRegistry extends never + ? string + : keyof MachineUserNameRegistry & string; /** Result of retrieving a connection token at runtime. */ export type AuthConnectionTokenResult = { @@ -107,7 +118,7 @@ export type UserAttributeListKey = { : never; }[UserFieldKeys]; -export type UserAttributeMap = { +export type UserAttributes = { [K in UserAttributeKey]?: true; }; @@ -130,19 +141,19 @@ type AttributeListToTuple< : never; }; -type AttributeMapSelectedKeys< +type SelectedAttributeKeys< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap, + Attributes extends UserAttributes, > = Extract< { - [K in keyof AttributeMap]-?: undefined extends AttributeMap[K] ? never : K; - }[keyof AttributeMap], + [K in keyof Attributes]-?: undefined extends Attributes[K] ? never : K; + }[keyof Attributes], UserAttributeKey >; type UserProfile< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap, + Attributes extends UserAttributes, AttributeList extends UserAttributeListKey[], > = { /** @@ -154,7 +165,7 @@ type UserProfile< namespace?: string; type: User; usernameField: UsernameFieldKey; - attributes?: DisallowExtraKeys>; + attributes?: DisallowExtraKeys>; attributeList?: AttributeList; }; @@ -192,29 +203,29 @@ type MachineUserFromAttributes = type MachineUserProfileAttributes< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap, + Attributes extends UserAttributes, > = NullableToOptional<{ - [K in AttributeMapSelectedKeys]: K extends keyof output + [K in SelectedAttributeKeys]: K extends keyof output ? output[K] : never; }> & { - [K in Exclude, AttributeMapSelectedKeys>]?: never; + [K in Exclude, SelectedAttributeKeys>]?: never; }; type MachineUserFromUserProfile< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap, + Attributes extends UserAttributes, AttributeList extends UserAttributeListKey[], -> = (AttributeMapSelectedKeys extends never +> = (SelectedAttributeKeys extends never ? { attributes?: never } - : OptionalIfNoRequiredKeys>) & + : OptionalIfNoRequiredKeys>) & ([] extends AttributeList ? { attributeList?: never } : { attributeList: AttributeListToTuple }); type MachineUser< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap = UserAttributeMap, + Attributes extends UserAttributes = UserAttributes, AttributeList extends UserAttributeListKey[] = [], MachineUserAttributes extends MachineUserAttributeFields | undefined = undefined, > = @@ -224,7 +235,7 @@ type MachineUser< attributes?: Record; attributeList?: string[]; } - : MachineUserFromUserProfile + : MachineUserFromUserProfile : [MachineUserAttributes] extends [MachineUserAttributeFields] ? MachineUserFromAttributes : IsAny extends true @@ -232,7 +243,7 @@ type MachineUser< attributes?: Record; attributeList?: string[]; } - : MachineUserFromUserProfile; + : MachineUserFromUserProfile; /** Upstream OAuth provider that federated a login through the Built-in IdP. */ export type FederatedIdentityProvider = "google" | "microsoft"; @@ -294,7 +305,7 @@ export type AuthHooks = { // Input type (before parsing) - used by configure layer export type AuthServiceInput< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap, + Attributes extends UserAttributes, AttributeList extends UserAttributeListKey[], MachineUserNames extends string, MachineUserAttributes extends MachineUserAttributeFields | undefined = @@ -303,11 +314,11 @@ export type AuthServiceInput< ConnectionNames extends string = string, > = { hooks?: AuthHooks; - userProfile?: UserProfile; + userProfile?: UserProfile; machineUserAttributes?: MachineUserAttributes; machineUsers?: Record< MachineUserNames, - MachineUser + MachineUser >; oauth2Clients?: Record; idProvider?: IdProviderConfig; @@ -320,25 +331,8 @@ export type AuthServiceInput< declare const authDefinitionBrand: unique symbol; export type AuthDefinitionBrand = { readonly [authDefinitionBrand]: true }; -type ConnectionNames = Config extends { connections?: Record } - ? K & string - : string; - -export type DefinedAuth = Config & { +export type DefinedAuth = Config & { name: Name; - /** - * @deprecated Pass the machine user name directly as a string instead, e.g. `authInvoker: "machine-user-name"`. - * Using this function pulls config-layer (Node-only) dependencies into runtime bundles. - */ - invoker(machineUser: M): AuthInvokerWithName; - /** - * @deprecated Use `authconnection.getConnectionToken(...)` from `@tailor-platform/sdk/runtime` instead. - * Importing `auth` from `tailor.config.ts` into runtime files pulls config-layer (Node-only) - * dependencies into the bundle. - */ - getConnectionToken>( - connectionName: C, - ): Promise; } & AuthDefinitionBrand; export type AuthExternalConfig = { name: string; external: true }; @@ -351,8 +345,7 @@ export type AuthOwnConfig = DefinedAuth< // Intentionally permissive: AuthConfig is the "container" type for AppConfig.auth. // We want any concrete `defineAuth(...)` result to be assignable here, while the // strong typing remains on the `defineAuth` return type itself. - AuthServiceInputLoose, - string + AuthServiceInputLoose >; export type AuthConfig = AuthOwnConfig | AuthExternalConfig; diff --git a/packages/sdk/src/configure/services/executor/executor.test.ts b/packages/sdk/src/configure/services/executor/executor.test.ts index ce87166a0..75ff1a28e 100644 --- a/packages/sdk/src/configure/services/executor/executor.test.ts +++ b/packages/sdk/src/configure/services/executor/executor.test.ts @@ -16,11 +16,11 @@ import { } from "./trigger/event"; import { scheduleTrigger } from "./trigger/schedule"; import { incomingWebhookTrigger } from "./trigger/webhook"; -import type { TailorInvoker } from "#/runtime/types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { Operation } from "./operation"; const createUserType = () => - db.type("User", { + db.table("User", { name: db.string(), age: db.int(), }); @@ -74,7 +74,7 @@ describe("createExecutor", () => { operation: { kind: "function", body: (args) => { - expectTypeOf(args).toEqualTypeOf(); + expectTypeOf(args).toEqualTypeOf(); }, }, }); @@ -972,7 +972,7 @@ describe("functionTarget", () => { operation: { kind: "function", body: (args) => { - expectTypeOf(args.invoker).toEqualTypeOf(); + expectTypeOf(args.invoker).toEqualTypeOf(); }, }, }); @@ -1197,6 +1197,60 @@ describe("workflowTarget", () => { expect(executor.operation.workflow.name).toBe("test-workflow"); }); + test("requires args for workflow with required input", () => { + createExecutor({ + name: "test", + trigger: scheduleTrigger({ cron: "0 12 * * *" }), + // @ts-expect-error - args is required by the workflow's main job input + operation: { + kind: "workflow", + workflow: testWorkflow, + }, + }); + }); + + test("accepts primitive static args", () => { + const primitiveJob = createWorkflowJob({ + name: "primitive-input-job", + body: (input: string) => input, + }); + const primitiveWorkflow = createWorkflow({ + name: "primitive-input-workflow", + mainJob: primitiveJob, + }); + + createExecutor({ + name: "test", + trigger: scheduleTrigger({ cron: "0 12 * * *" }), + operation: { + kind: "workflow", + workflow: primitiveWorkflow, + args: "hello", + }, + }); + }); + + test("accepts array static args", () => { + const arrayJob = createWorkflowJob({ + name: "array-input-job", + body: (input: string[]) => input.length, + }); + const arrayWorkflow = createWorkflow({ + name: "array-input-workflow", + mainJob: arrayJob, + }); + + createExecutor({ + name: "test", + trigger: scheduleTrigger({ cron: "0 12 * * *" }), + operation: { + kind: "workflow", + workflow: arrayWorkflow, + args: ["hello"], + }, + }); + }); + test("args can be a function", () => { createExecutor({ name: "test", @@ -1264,7 +1318,7 @@ describe("workflowTarget", () => { }); }); - test("can specify authInvoker", () => { + test("can specify invoker", () => { createExecutor({ name: "test", trigger: scheduleTrigger({ cron: "0 12 * * *" }), @@ -1272,7 +1326,7 @@ describe("workflowTarget", () => { kind: "workflow", workflow: testWorkflow, args: { orderId: "test-id" }, - authInvoker: { namespace: "my-auth", machineUserName: "admin" }, + invoker: "admin", }, }); }); diff --git a/packages/sdk/src/configure/services/executor/executor.ts b/packages/sdk/src/configure/services/executor/executor.ts index ba9edc21c..c9bb62a97 100644 --- a/packages/sdk/src/configure/services/executor/executor.ts +++ b/packages/sdk/src/configure/services/executor/executor.ts @@ -1,16 +1,9 @@ import { brandValue } from "#/utils/brand"; -import type { AuthInvoker } from "#/configure/services/auth/index"; import type { Workflow } from "#/configure/services/workflow/workflow"; -import type { MachineUserName } from "#/configure/types/machine-user"; import type { ExecutorInput } from "#/types/executor.generated"; -import type { Operation } from "./operation"; +import type { Operation, WorkflowOperation } from "./operation"; import type { Trigger } from "./trigger"; -/** - * Extract mainJob's Input type from Workflow. - */ -type WorkflowInput = Parameters[0]; - type TriggerArgs> = T extends { __args: infer Args } ? Args : never; type ExecutorBase> = Omit & { @@ -27,12 +20,7 @@ type Executor, O> = O extends { workflow: infer W extends Workflow; } ? ExecutorBase & { - operation: { - kind: "workflow"; - workflow: W; - args?: WorkflowInput | ((args: TriggerArgs) => WorkflowInput); - authInvoker?: AuthInvoker | MachineUserName; - }; + operation: WorkflowOperation, W>; } : ExecutorBase & { operation: O; diff --git a/packages/sdk/src/configure/services/executor/operation.ts b/packages/sdk/src/configure/services/executor/operation.ts index 92b6b34c5..3c66fdf60 100644 --- a/packages/sdk/src/configure/services/executor/operation.ts +++ b/packages/sdk/src/configure/services/executor/operation.ts @@ -1,7 +1,6 @@ -import type { AuthInvoker } from "#/configure/services/auth/index"; import type { Workflow } from "#/configure/services/workflow/workflow"; import type { MachineUserName } from "#/configure/types/machine-user"; -import type { TailorInvoker } from "#/runtime/types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { FunctionOperation as ParserFunctionOperation, GqlOperation as ParserGqlOperation, @@ -11,18 +10,18 @@ import type { import type { Client } from "@urql/core"; /** Function-based executor operation. The body receives the trigger args and the `invoker`. */ -export type FunctionOperation = Omit & { - body: (args: Args & { invoker?: TailorInvoker }) => void | Promise; - authInvoker?: AuthInvoker | MachineUserName; +export type FunctionOperation = Omit & { + body: (args: Args & { invoker: TailorPrincipal | null }) => void | Promise; + invoker?: MachineUserName; }; type UrqlOperationArgs = Parameters; /** GraphQL-based executor operation. Executes a GraphQL query or mutation. */ -export type GqlOperation = Omit & { +export type GqlOperation = Omit & { query: UrqlOperationArgs[0]; variables?: (args: Args) => UrqlOperationArgs[1]; - authInvoker?: AuthInvoker | MachineUserName; + invoker?: MachineUserName; }; type RequestHeader = @@ -286,17 +285,23 @@ export type WebhookOperation = Omit< * Extract mainJob's Input type from Workflow. * Workflow -> Job is WorkflowJob -> Input */ -type WorkflowInput = Parameters[0]; +type WorkflowInput = Parameters[0]; + +type WorkflowArgs = WorkflowInput | ((args: Args) => WorkflowInput); + +type WorkflowArgsProperty = + undefined extends WorkflowInput + ? { args?: WorkflowArgs } + : { args: WorkflowArgs }; /** Workflow-triggering executor operation. Triggers a workflow in response to an event. */ export type WorkflowOperation = Omit< ParserWorkflowOperation, - "workflowName" | "args" | "authInvoker" + "workflowName" | "args" | "invoker" > & { workflow: W; - args?: WorkflowInput | ((args: Args) => WorkflowInput); - authInvoker?: AuthInvoker | MachineUserName; -}; + invoker?: MachineUserName; +} & WorkflowArgsProperty; export type Operation = | FunctionOperation diff --git a/packages/sdk/src/configure/services/executor/trigger/event.ts b/packages/sdk/src/configure/services/executor/trigger/event.ts index e8286546e..8e5469281 100644 --- a/packages/sdk/src/configure/services/executor/trigger/event.ts +++ b/packages/sdk/src/configure/services/executor/trigger/event.ts @@ -1,7 +1,7 @@ import type { ResolverConfig } from "#/configure/services/resolver/resolver"; import type { TailorDBType } from "#/configure/services/tailordb/schema"; import type { IdpName } from "#/configure/types/idp-name"; -import type { TailorActor, TailorEnv } from "#/runtime/types"; +import type { TailorEnv, TailorPrincipal } from "#/runtime/types"; import type { TailorDBTrigger as ParserTailorDBTrigger, ResolverExecutedTrigger as ParserResolverExecutedTrigger, @@ -14,7 +14,7 @@ interface EventArgs { workspaceId: string; appNamespace: string; env: TailorEnv; - actor: TailorActor | null; + actor: TailorPrincipal | null; } interface RecordArgs extends EventArgs { diff --git a/packages/sdk/src/configure/services/idp/permission.test.ts b/packages/sdk/src/configure/services/idp/permission.test.ts index 4aa4260b9..601f1cd83 100644 --- a/packages/sdk/src/configure/services/idp/permission.test.ts +++ b/packages/sdk/src/configure/services/idp/permission.test.ts @@ -47,6 +47,36 @@ describe("IdPPermissionCondition type checks", () => { }); }); +describe("IdPPermissionCondition with optional attribute fields", () => { + type OptionalAttrs = { + role?: string; + permissions?: string[]; + active?: boolean; + flags?: boolean[]; + }; + + test("accepts user operands referencing optional attribute fields", () => { + const _str: IdPPermissionCondition = [{ user: "role" }, "=", "ADMIN"]; + const _bool: IdPPermissionCondition = [{ user: "active" }, "=", true]; + const _strArr: IdPPermissionCondition = [ + "a", + "in", + { user: "permissions" }, + ]; + expectTypeOf(_str).toExtend>(); + expectTypeOf(_bool).toExtend>(); + expectTypeOf(_strArr).toExtend>(); + }); + + test("does not leak undefined into user operand keys", () => { + type UserOperandKeys = Extract< + IdPPermissionCondition[0], + { user: unknown } + >["user"]; + expectTypeOf().not.toExtend(); + }); +}); + describe("IdPPermission type checks", () => { test("accepts valid full permission", () => { const _perm: IdPPermission = { diff --git a/packages/sdk/src/configure/services/idp/permission.ts b/packages/sdk/src/configure/services/idp/permission.ts index 679750708..6e7b797d4 100644 --- a/packages/sdk/src/configure/services/idp/permission.ts +++ b/packages/sdk/src/configure/services/idp/permission.ts @@ -1,38 +1,40 @@ import type { IdPUserField } from "#/parser/service/idp/types"; -import type { InferredAttributeMap } from "#/runtime/types"; +import type { InferredAttributes } from "#/runtime/types"; type EqualityOperator = "=" | "!="; type ContainsOperator = "in" | "not in"; +// `-?` keeps the indexed access from folding optional fields' `never` into +// `undefined`; `Exclude` lets an optional field's value type still match. type StringFieldKeys = { - [K in keyof User]: User[K] extends string ? K : never; + [K in keyof User]-?: Exclude extends string ? K : never; }[keyof User]; type StringArrayFieldKeys = { - [K in keyof User]: User[K] extends string[] ? K : never; + [K in keyof User]-?: Exclude extends string[] ? K : never; }[keyof User]; type BooleanFieldKeys = { - [K in keyof User]: User[K] extends boolean ? K : never; + [K in keyof User]-?: Exclude extends boolean ? K : never; }[keyof User]; type BooleanArrayFieldKeys = { - [K in keyof User]: User[K] extends boolean[] ? K : never; + [K in keyof User]-?: Exclude extends boolean[] ? K : never; }[keyof User]; -type UserStringOperand = { +type UserStringOperand = { user: StringFieldKeys | "id"; }; -type UserStringArrayOperand = { +type UserStringArrayOperand = { user: StringArrayFieldKeys; }; -type UserBooleanOperand = { +type UserBooleanOperand = { user: BooleanFieldKeys | "_loggedIn"; }; -type UserBooleanArrayOperand = { +type UserBooleanArrayOperand = { user: BooleanArrayFieldKeys; }; @@ -63,7 +65,7 @@ type BooleanEqualityCondition = | readonly [boolean | UserBooleanOperand, EqualityOperator, IdPUserOperand]; type EqualityCondition< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, > = StringEqualityCondition | BooleanEqualityCondition; @@ -80,17 +82,17 @@ type BooleanContainsCondition = | readonly [IdPUserOperand, ContainsOperator, boolean[] | UserBooleanArrayOperand]; type ContainsCondition< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, > = StringContainsCondition | BooleanContainsCondition; export type IdPPermissionCondition< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, > = EqualityCondition | ContainsCondition; type IdPActionPermission< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, > = | { @@ -124,7 +126,7 @@ type IdPActionPermission< * unenrollMfa: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }], * }; */ -export type IdPPermission = { +export type IdPPermission = { create: readonly IdPActionPermission[]; read: readonly IdPActionPermission[]; update: readonly IdPActionPermission[]; diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index 063c1e19c..8d857e09d 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -2,7 +2,7 @@ import { describe, expectTypeOf, test, expect } from "vitest"; import { db } from "#/configure/services/tailordb/index"; import { t } from "#/configure/types/index"; import { createResolver } from "./resolver"; -import type { TailorInvoker, TailorUser } from "#/runtime/types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { output } from "#/types/helpers"; import type { ResolverInput } from "#/types/resolver.generated"; @@ -14,11 +14,11 @@ describe("createResolver", () => { operation: "query", output: t.object({ result: t.string() }), body: (context) => { - expectTypeOf(context).toHaveProperty("user"); + expectTypeOf(context).toHaveProperty("caller"); expectTypeOf(context).toHaveProperty("input"); expectTypeOf(context).toHaveProperty("invoker"); - expectTypeOf(context.user).toEqualTypeOf(); - expectTypeOf(context.invoker).toEqualTypeOf(); + expectTypeOf(context.caller).toEqualTypeOf(); + expectTypeOf(context.invoker).toEqualTypeOf(); expectTypeOf(context.input).toBeNever(); return { result: "hello" }; }, @@ -31,9 +31,9 @@ describe("createResolver", () => { operation: "mutation", output: t.object({ success: t.bool() }), body: (context) => { - expectTypeOf(context).toHaveProperty("user"); + expectTypeOf(context).toHaveProperty("caller"); expectTypeOf(context).toHaveProperty("input"); - expectTypeOf(context.user).toEqualTypeOf(); + expectTypeOf(context.caller).toEqualTypeOf(); expectTypeOf(context.input).toBeNever(); return { success: true }; }, @@ -53,7 +53,7 @@ describe("createResolver", () => { output: t.object({ message: t.string() }), body: (context) => { expectTypeOf(context).toHaveProperty("input"); - expectTypeOf(context).toHaveProperty("user"); + expectTypeOf(context).toHaveProperty("caller"); expectTypeOf(context.input).toEqualTypeOf<{ name: string; age: number; @@ -221,7 +221,7 @@ describe("createResolver", () => { output: t.object({ data: t.string() }), body: async (context) => { expectTypeOf(context).toHaveProperty("input"); - expectTypeOf(context).toHaveProperty("user"); + expectTypeOf(context).toHaveProperty("caller"); await new Promise((resolve) => setTimeout(resolve, 0)); return { data: context.input.id }; }, @@ -236,23 +236,24 @@ describe("createResolver", () => { output: t.object({ success: t.bool() }), body: async (context) => { expectTypeOf(context).toHaveProperty("input"); - expectTypeOf(context).toHaveProperty("user"); + expectTypeOf(context).toHaveProperty("caller"); return { success: true }; }, }); }); - test("user context always available", () => { + test("caller context is nullable", () => { createResolver({ name: "withUser", operation: "query", output: t.object({ userId: t.string() }), body: (context) => { - expectTypeOf(context.user).toEqualTypeOf(); - expectTypeOf(context.user.id).toBeString(); - expectTypeOf(context.user.type).toBeString(); - expectTypeOf(context.user.workspaceId).toBeString(); - return { userId: context.user.id }; + expectTypeOf(context.caller).toEqualTypeOf(); + if (!context.caller) return { userId: "anonymous" }; + expectTypeOf(context.caller.id).toBeString(); + expectTypeOf(context.caller.type).toEqualTypeOf<"user" | "machine_user">(); + expectTypeOf(context.caller.workspaceId).toBeString(); + return { userId: context.caller.id }; }, }); }); @@ -450,19 +451,21 @@ describe("createResolver", () => { expect(typeof resolver.body).toBe("function"); }); - test("creates resolver with authInvoker", () => { - const outputType = t.object({ result: t.string() }); + test("creates resolver with invoker", () => { + const outputType = t.object({ + result: t.string(), + }); const resolver = createResolver({ - name: "withAuthInvoker", + name: "withInvoker", operation: "query", output: outputType, body: () => ({ result: "ok" }), - authInvoker: { namespace: "my-auth", machineUserName: "batch-user" }, + invoker: "batch-user", }); - expect(resolver.name).toBe("withAuthInvoker"); - expect(resolver.authInvoker).toEqual({ namespace: "my-auth", machineUserName: "batch-user" }); + expect(resolver.name).toBe("withInvoker"); + expect(resolver.invoker).toBe("batch-user"); }); test("creates minimal resolver without optional fields", () => { diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index 24db05630..bffccc2fd 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -1,15 +1,14 @@ import { t, type TailorAnyField, type TailorField } from "#/configure/types/type"; import { brandValue } from "#/utils/brand"; -import type { AuthInvoker } from "#/configure/services/auth/index"; import type { MachineUserName } from "#/configure/types/machine-user"; -import type { TailorEnv, TailorInvoker, TailorUser } from "#/runtime/types"; +import type { TailorEnv, TailorPrincipal } from "#/runtime/types"; import type { InferFieldsOutput, output } from "#/types/helpers"; import type { ResolverInput } from "#/types/resolver.generated"; type Context | undefined> = { input: Input extends Record ? InferFieldsOutput : never; - user: TailorUser; - invoker?: TailorInvoker; + caller: TailorPrincipal | null; + invoker: TailorPrincipal | null; env: TailorEnv; }; @@ -35,19 +34,19 @@ type NormalizedOutput | undefined, Output extends TailorAnyField | Record, -> = Omit & +> = Omit & Readonly<{ input?: Input; output: NormalizedOutput; body: (context: Context) => OutputType | Promise>; - authInvoker?: AuthInvoker | MachineUserName; + invoker?: MachineUserName; }>; /** * Create a resolver definition for the Tailor SDK. * * The `body` function receives a context with `input` (typed from `config.input`), - * `user`, `invoker` (reflects `authInvoker` delegation), and `env`. + * `caller`, `invoker` (reflects configured machine-user delegation), and `env`. * The return value of `body` must match the `output` type. * * `output` accepts either a single TailorField (e.g. `t.string()`) or a @@ -70,7 +69,7 @@ type ResolverReturn< * input: { * id: t.string(), * }, - * body: async ({ input, user }) => { + * body: async ({ input, caller }) => { * const db = getDB("tailordb"); * const result = await db.selectFrom("User").selectAll().where("id", "=", input.id).executeTakeFirst(); * return { name: result?.name ?? "", email: result?.email ?? "" }; @@ -86,12 +85,12 @@ export function createResolver< Input extends Record | undefined = undefined, Output extends TailorAnyField | Record = TailorAnyField, >( - config: Omit & + config: Omit & Readonly<{ input?: Input; output: Output; body: (context: Context) => OutputType | Promise>; - authInvoker?: AuthInvoker | MachineUserName; + invoker?: MachineUserName; }>, ): ResolverReturn { // Check if output is already a TailorField using duck typing. diff --git a/packages/sdk/src/configure/services/tailordb/permission.test.ts b/packages/sdk/src/configure/services/tailordb/permission.test.ts index 0a6713696..11d0fb5cb 100644 --- a/packages/sdk/src/configure/services/tailordb/permission.test.ts +++ b/packages/sdk/src/configure/services/tailordb/permission.test.ts @@ -1,5 +1,5 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. -import { describe, test } from "vitest"; +import { describe, expectTypeOf, test } from "vitest"; import type { PermissionCondition } from "./permission"; describe("tailordb permission types", () => { @@ -109,3 +109,39 @@ describe("tailordb permission types", () => { }); }); }); + +describe("tailordb permission types with optional user attribute fields", () => { + type OptionalUser = { + role?: string; + permissions?: string[]; + isAdmin?: boolean; + flags?: boolean[]; + }; + + test("user operand accepts keys derived from optional fields", () => { + const _str = [{ user: "role" }, "=", "ADMIN"] satisfies PermissionCondition< + "record", + OptionalUser + >; + const _bool = [{ user: "isAdmin" }, "=", true] satisfies PermissionCondition< + "record", + OptionalUser + >; + const _strArr = ["a", "in", { user: "permissions" }] satisfies PermissionCondition< + "record", + OptionalUser + >; + const _boolArr = [true, "in", { user: "flags" }] satisfies PermissionCondition< + "record", + OptionalUser + >; + }); + + test("does not leak undefined into user operand keys", () => { + type UserOperandKeys = Extract< + PermissionCondition<"record", OptionalUser>[0], + { user: unknown } + >["user"]; + expectTypeOf().not.toExtend(); + }); +}); diff --git a/packages/sdk/src/configure/services/tailordb/permission.ts b/packages/sdk/src/configure/services/tailordb/permission.ts index 5562ecebb..3e0273f02 100644 --- a/packages/sdk/src/configure/services/tailordb/permission.ts +++ b/packages/sdk/src/configure/services/tailordb/permission.ts @@ -1,4 +1,4 @@ -import type { InferredAttributeMap } from "#/runtime/types"; +import type { InferredAttributes } from "#/runtime/types"; // --- Permission types (UX-focused, for configure layer) --- @@ -19,7 +19,7 @@ import type { InferredAttributeMap } from "#/runtime/types"; * }; */ export type TailorTypePermission< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Type extends object = object, > = { create: readonly ActionPermission<"record", User, Type, false>[]; @@ -30,7 +30,7 @@ export type TailorTypePermission< type ActionPermission< Level extends "record" | "gql" = "record" | "gql", - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Type extends object = object, Update extends boolean = boolean, > = @@ -50,14 +50,11 @@ type ActionPermission< | readonly [...PermissionCondition[], ...([] | [boolean])]; // multiple array condition export type TailorTypeGqlPermission< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Type extends object = object, > = readonly GqlPermissionPolicy[]; -type GqlPermissionPolicy< - User extends object = InferredAttributeMap, - Type extends object = object, -> = { +type GqlPermissionPolicy = { conditions: readonly PermissionCondition<"gql", User, boolean, Type>[]; actions: "all" | readonly GqlPermissionAction[]; /** @@ -75,35 +72,37 @@ type ContainsOperator = "in" | "not in"; type HasAnyOperator = "hasAny" | "not hasAny"; // Helper types for User field extraction +// `-?` keeps the indexed access from folding optional fields' `never` into +// `undefined`; `Exclude` lets an optional field's value type still match. type StringFieldKeys = { - [K in keyof User]: User[K] extends string ? K : never; + [K in keyof User]-?: Exclude extends string ? K : never; }[keyof User]; type StringArrayFieldKeys = { - [K in keyof User]: User[K] extends string[] ? K : never; + [K in keyof User]-?: Exclude extends string[] ? K : never; }[keyof User]; type BooleanFieldKeys = { - [K in keyof User]: User[K] extends boolean ? K : never; + [K in keyof User]-?: Exclude extends boolean ? K : never; }[keyof User]; type BooleanArrayFieldKeys = { - [K in keyof User]: User[K] extends boolean[] ? K : never; + [K in keyof User]-?: Exclude extends boolean[] ? K : never; }[keyof User]; -type UserStringOperand = { +type UserStringOperand = { user: StringFieldKeys | "id"; }; -type UserStringArrayOperand = { +type UserStringArrayOperand = { user: StringArrayFieldKeys; }; -type UserBooleanOperand = { +type UserBooleanOperand = { user: BooleanFieldKeys | "_loggedIn"; }; -type UserBooleanArrayOperand = { +type UserBooleanArrayOperand = { user: BooleanArrayFieldKeys; }; @@ -160,7 +159,7 @@ type BooleanEqualityCondition< type EqualityCondition< Level extends "record" | "gql" = "record", - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, Type extends object = object, > = @@ -216,7 +215,7 @@ type BooleanContainsCondition< type ContainsCondition< Level extends "record" | "gql" = "record", - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, Type extends object = object, > = @@ -251,7 +250,7 @@ type HasAnyCondition< /** * Type representing a permission condition that combines user attributes, record fields, and literal values using comparison operators. * - * The User type is extended by `tailor.d.ts`, which is automatically generated when running `tailor-sdk generate`. + * The User type is extended by `tailor.d.ts`, which is automatically generated when running `tailor generate`. * Attributes enabled in the config file's `auth.userProfile.attributes` (or * `auth.machineUserAttributes` when userProfile is omitted) become available as types. * @example @@ -270,7 +269,7 @@ type HasAnyCondition< */ export type PermissionCondition< Level extends "record" | "gql" = "record", - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, Type extends object = object, > = diff --git a/packages/sdk/src/configure/services/tailordb/schema.test.ts b/packages/sdk/src/configure/services/tailordb/schema.test.ts index b2c33e193..09668f33f 100644 --- a/packages/sdk/src/configure/services/tailordb/schema.test.ts +++ b/packages/sdk/src/configure/services/tailordb/schema.test.ts @@ -1,21 +1,16 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. import { describe, expectTypeOf, expect, test } from "vitest"; import { t } from "#/configure/types/index"; -import { unauthenticatedTailorUser } from "#/configure/user"; import { db, type TailorAnyDBField, type TailorDBType } from "./schema"; -import type { - FieldOptions, - FieldValidateInput, - ValidateConfig, -} from "#/configure/types/field.types"; -import type { TailorUser } from "#/runtime/types"; +import type { FieldOptions, FieldValidateInput } from "#/configure/types/field.types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { output, TypeLevelError } from "#/types/helpers"; import type { Hook } from "./types"; import type { StandardSchemaV1 } from "@standard-schema/spec"; describe("TailorDBField basic field type tests", () => { test("string field outputs string type correctly", () => { - const _stringType = db.type("Test", { + const _stringType = db.table("Test", { name: db.string(), }); expectTypeOf>().toEqualTypeOf<{ @@ -25,7 +20,7 @@ describe("TailorDBField basic field type tests", () => { }); test("int field outputs number type correctly", () => { - const _intType = db.type("Test", { + const _intType = db.table("Test", { age: db.int(), }); expectTypeOf>().toEqualTypeOf<{ @@ -35,7 +30,7 @@ describe("TailorDBField basic field type tests", () => { }); test("bool field outputs boolean type correctly", () => { - const _boolType = db.type("Test", { + const _boolType = db.table("Test", { active: db.bool(), }); expectTypeOf>().toEqualTypeOf<{ @@ -45,7 +40,7 @@ describe("TailorDBField basic field type tests", () => { }); test("float field outputs number type correctly", () => { - const _floatType = db.type("Test", { + const _floatType = db.table("Test", { price: db.float(), }); expectTypeOf>().toEqualTypeOf<{ @@ -55,7 +50,7 @@ describe("TailorDBField basic field type tests", () => { }); test("uuid field outputs string type correctly", () => { - const _uuidType = db.type("Test", { + const _uuidType = db.table("Test", { uuid: db.uuid(), }); expectTypeOf>().toEqualTypeOf<{ @@ -65,7 +60,7 @@ describe("TailorDBField basic field type tests", () => { }); test("date field outputs string type correctly", () => { - const _dateType = db.type("Test", { + const _dateType = db.table("Test", { birthDate: db.date(), }); expectTypeOf>().toEqualTypeOf<{ @@ -75,7 +70,7 @@ describe("TailorDBField basic field type tests", () => { }); test("datetime field outputs string | Date type correctly", () => { - const _datetimeType = db.type("Test", { + const _datetimeType = db.table("Test", { timestamp: db.datetime(), }); expectTypeOf>().toMatchObjectType<{ @@ -85,7 +80,7 @@ describe("TailorDBField basic field type tests", () => { }); test("time field outputs string type correctly", () => { - const _timeType = db.type("Test", { + const _timeType = db.table("Test", { openingTime: db.time(), }); expectTypeOf>().toEqualTypeOf<{ @@ -97,7 +92,7 @@ describe("TailorDBField basic field type tests", () => { describe("TailorDBField optional option tests", () => { test("optional option generates nullable type", () => { - const _optionalType = db.type("Test", { + const _optionalType = db.table("Test", { description: db.string({ optional: true }), }); expectTypeOf>().toEqualTypeOf<{ @@ -107,7 +102,7 @@ describe("TailorDBField optional option tests", () => { }); test("multiple optional fields work correctly", () => { - const _multiOptionalType = db.type("Test", { + const _multiOptionalType = db.table("Test", { title: db.string(), description: db.string({ optional: true }), count: db.int({ optional: true }), @@ -123,7 +118,7 @@ describe("TailorDBField optional option tests", () => { describe("TailorDBField array option tests", () => { test("array option generates array type", () => { - const _arrayType = db.type("Test", { + const _arrayType = db.table("Test", { tags: db.string({ array: true }), }); expectTypeOf>().toEqualTypeOf<{ @@ -133,7 +128,7 @@ describe("TailorDBField array option tests", () => { }); test("optional array works correctly", () => { - const _optionalArrayType = db.type("Test", { + const _optionalArrayType = db.table("Test", { items: db.string({ optional: true, array: true }), }); expectTypeOf>().toEqualTypeOf<{ @@ -143,7 +138,7 @@ describe("TailorDBField array option tests", () => { }); test("multiple array fields work correctly", () => { - const _multiArrayType = db.type("Test", { + const _multiArrayType = db.table("Test", { tags: db.string({ array: true }), numbers: db.int({ array: true }), flags: db.bool({ array: true }), @@ -200,7 +195,7 @@ describe("TailorDBField enum field tests", () => { }); test("optional enum() works correctly", () => { - const _optionalEnumType = db.type("Test", { + const _optionalEnumType = db.table("Test", { priority: db.enum(["high", "medium", "low"], { optional: true }), }); expectTypeOf>().toEqualTypeOf<{ @@ -221,7 +216,7 @@ describe("TailorDBField enum field tests", () => { }); test("enum array works correctly", () => { - const _enumArrayType = db.type("Test", { + const _enumArrayType = db.table("Test", { categories: db.enum(["a", "b", "c"], { array: true }), }); expectTypeOf>().toEqualTypeOf<{ @@ -232,12 +227,12 @@ describe("TailorDBField enum field tests", () => { }); describe("TailorDBField RelationConfig option field tests", () => { - const User = db.type("User", { + const User = db.table("User", { name: db.string(), email: db.string(), }); - const Customer = db.type("Customer", { + const Customer = db.table("Customer", { name: db.string(), customerId: db.string(), }); @@ -363,7 +358,7 @@ describe("TailorDBField RelationConfig option field tests", () => { describe("TailorDBField modifier chain tests", () => { test("index() modifier does not affect type", () => { - const _indexType = db.type("Test", { + const _indexType = db.table("Test", { email: db.string().index(), }); expectTypeOf>().toEqualTypeOf<{ @@ -373,7 +368,7 @@ describe("TailorDBField modifier chain tests", () => { }); test("unique() modifier does not affect type", () => { - const _uniqueType = db.type("Test", { + const _uniqueType = db.table("Test", { username: db.string().unique(), }); expectTypeOf>().toEqualTypeOf<{ @@ -399,7 +394,7 @@ describe("TailorDBField type error message tests", () => { TypeLevelError<".description() has already been set"> >(); - const _userType = db.type("User", { + const _userType = db.table("User", { name: db.string(), }); const related = db.uuid().relation({ @@ -448,7 +443,7 @@ describe("TailorDBField type error message tests", () => { TypeLevelError<"hooks cannot be set on nested type fields"> >(); - const validated = db.string().validate(() => true); + const validated = db.string().validate(() => undefined); expectTypeOf(validated.validate).toEqualTypeOf< TypeLevelError<".validate() has already been set"> >(); @@ -465,6 +460,23 @@ describe("TailorDBField type error message tests", () => { expectTypeOf(db.string({ optional: true }).serial).toEqualTypeOf< TypeLevelError<"serial can only be set on non-array integer or string fields"> >(); + + const defaulted = db.string().default("hello"); + expectTypeOf(defaulted.default).toEqualTypeOf< + TypeLevelError<".default() has already been set"> + >(); + + expectTypeOf(db.object({ x: db.string() }).default).toEqualTypeOf< + TypeLevelError<"default cannot be set on nested type fields"> + >(); + + expectTypeOf(db.string().serial({ start: 0 }).default).toEqualTypeOf< + TypeLevelError<"default cannot be set on serial fields"> + >(); + + expectTypeOf(db.string({ optional: true }).default).toEqualTypeOf< + TypeLevelError<"default cannot be set on optional fields"> + >(); }); test("field types stay assignable when a module helper erases fields via an unresolved generic", () => { @@ -474,44 +486,48 @@ describe("TailorDBField type error message tests", () => { function withCustomFields< const F extends Record = Record, >(fields?: F) { - return db.type("WithCustomFields", { + return db.table("WithCustomFields", { tags: db.string({ array: true }).description("array field to catch array-widening bugs"), ...(fields ?? ({} as F)), }); } - type GenericDefaultShape = ReturnType; const concrete = withCustomFields({ name: db.string() }); - expectTypeOf(concrete).toExtend(); + expectTypeOf(concrete.fields.tags).toEqualTypeOf(withCustomFields().fields.tags); + expectTypeOf(concrete.fields.name).not.toBeAny(); }); }); describe("TailorDBType type error message tests", () => { test("duplicate type modifiers expose type-level error messages", () => { - const hooked = db.type("HookedUser", { name: db.string() }).hooks({ - name: { create: () => "created" }, + const hooked = db.table("HookedUser", { name: db.string() }).hooks({ + create: ({ input }) => ({ name: input.name }), }); expectTypeOf[0]>().toEqualTypeOf< TypeLevelError<".hooks() has already been set"> >(); expect(() => { // @ts-expect-error hooks() cannot be called after hooks() has already been called - hooked.hooks({ name: { update: () => "updated" } }); + hooked.hooks({ update: ({ input }) => ({ name: input.name }) }); }).toThrowError(".hooks() has already been set"); - const validated = db.type("ValidatedUser", { name: db.string() }).validate({ - name: ({ value }) => value.length > 0, - }); + const validated = db + .table("ValidatedUser", { name: db.string() }) + .validate(({ newRecord }, issues) => { + if (!(newRecord.name.length > 0)) issues("name", "must not be empty"); + }); expectTypeOf[0]>().toEqualTypeOf< TypeLevelError<".validate() has already been set"> >(); expect(() => { // @ts-expect-error validate() cannot be called after validate() has already been called - validated.validate({ name: ({ value }) => value.length > 1 }); + validated.validate(({ newRecord }, issues) => { + if (!(newRecord.name.length > 1)) issues("name", "too short"); + }); }).toThrowError(".validate() has already been set"); - const featured = db.type("FeaturedUser", { name: db.string() }).features({ + const featured = db.table("FeaturedUser", { name: db.string() }).features({ aggregation: true, }); expectTypeOf[0]>().toEqualTypeOf< @@ -522,7 +538,7 @@ describe("TailorDBType type error message tests", () => { featured.features({ bulkUpsert: true }); }).toThrowError(".features() has already been set"); - const indexed = db.type("IndexedUser", { name: db.string() }).indexes({ + const indexed = db.table("IndexedUser", { name: db.string() }).indexes({ fields: ["id", "name"], }); expectTypeOf>().toEqualTypeOf< @@ -542,7 +558,7 @@ describe("TailorDBType type error message tests", () => { indexed.indexes({ fields: ["id", "name"] }); }).toThrowError(".indexes() has already been set"); - const permitted = db.type("PermittedUser", { name: db.string() }).permission({ + const permitted = db.table("PermittedUser", { name: db.string() }).permission({ create: [], read: [], update: [], @@ -562,7 +578,7 @@ describe("TailorDBType type error message tests", () => { permitted.permission(duplicatePermission); }).toThrowError(".permission() has already been set"); - const gqlPermitted = db.type("GqlPermittedUser", { name: db.string() }).gqlPermission([]); + const gqlPermitted = db.table("GqlPermittedUser", { name: db.string() }).gqlPermission([]); expectTypeOf[0]>().toEqualTypeOf< TypeLevelError<".gqlPermission() has already been set"> >(); @@ -571,7 +587,7 @@ describe("TailorDBType type error message tests", () => { gqlPermitted.gqlPermission([]); }).toThrowError(".gqlPermission() has already been set"); - const withFiles = db.type("UserWithFiles", { name: db.string() }).files({ + const withFiles = db.table("UserWithFiles", { name: db.string() }).files({ avatar: "profile image", }); expectTypeOf[0]>().toEqualTypeOf< @@ -582,7 +598,7 @@ describe("TailorDBType type error message tests", () => { withFiles.files({ document: "user document" }); }).toThrowError(".files() has already been set"); - const described = db.type("DescribedUser", { name: db.string() }).description("first"); + const described = db.table("DescribedUser", { name: db.string() }).description("first"); expectTypeOf[0]>().toEqualTypeOf< TypeLevelError<".description() has already been set"> >(); @@ -591,7 +607,7 @@ describe("TailorDBType type error message tests", () => { described.description("second"); }).toThrowError(".description() has already been set"); - const describedInTypeCall = db.type("DescribedInTypeCallUser", "first", { + const describedInTypeCall = db.table("DescribedInTypeCallUser", "first", { name: db.string(), }); expectTypeOf[0]>().toEqualTypeOf< @@ -603,8 +619,8 @@ describe("TailorDBType type error message tests", () => { }).toThrowError(".description() has already been set"); const describedAfterHooks = db - .type("DescribedAfterHooksUser", { name: db.string() }) - .hooks({ name: { create: () => "created" } }) + .table("DescribedAfterHooksUser", { name: db.string() }) + .hooks({ create: ({ input }) => ({ name: input.name }) }) .description("user with hooks"); expectTypeOf[0]>().toEqualTypeOf< TypeLevelError<".hooks() has already been set"> @@ -616,7 +632,7 @@ describe("TailorDBType type error message tests", () => { return { avatar: "profile image" }; } - let conditional = db.type("ConditionallyFileUser", { name: db.string() }); + let conditional = db.table("ConditionallyFileUser", { name: db.string() }); const files = getFiles(); if (files) { @@ -627,14 +643,14 @@ describe("TailorDBType type error message tests", () => { }); test("duplicate type modifiers throw after type state is erased", () => { - const alias = db.type("RuntimeAliasFilesUser", { name: db.string() }); + const alias = db.table("RuntimeAliasFilesUser", { name: db.string() }); alias.files({ avatar: "profile image" }); expect(() => { alias.files({ document: "user document" }); }).toThrowError(".files() has already been set"); const erased: TailorDBType = db - .type("RuntimeErasedFilesUser", { name: db.string() }) + .table("RuntimeErasedFilesUser", { name: db.string() }) .files({ avatar: "profile image" }); expect(() => { erased.files({ document: "user document" }); @@ -642,28 +658,21 @@ describe("TailorDBType type error message tests", () => { }); test("failed duplicate-guarded calls can be retried", () => { - const retryable = db.type("RetryableHookedUser", { name: db.string() }); - const invalidHooks = { - missing: { create: () => "created" }, - } as unknown as Parameters[0]; + const retryable = db.table("RetryableHookedUser", { name: db.string() }); + retryable.hooks({ create: ({ input }) => ({ name: input.name }) }); expect(() => { - retryable.hooks(invalidHooks); - }).toThrowError("field not found: missing"); - - retryable.hooks({ name: { create: () => "created" } }); - expect(() => { - retryable.hooks({ name: { update: () => "updated" } }); + retryable.hooks({ update: ({ input }) => ({ name: input.name ?? "" }) }); }).toThrowError(".hooks() has already been set"); }); }); describe("TailorDBField relation modifier tests", () => { test("relation does not create reference type", () => { - const _userType = db.type("User", { + const _userType = db.table("User", { name: db.string(), }); - const _postType = db.type("Post", { + const _postType = db.table("Post", { title: db.string(), authorId: db.uuid().relation({ type: "oneToOne", @@ -679,7 +688,7 @@ describe("TailorDBField relation modifier tests", () => { }); test("attempting to set relation twice causes type error", () => { - const _userType = db.type("User", { + const _userType = db.table("User", { name: db.string(), }); @@ -697,7 +706,7 @@ describe("TailorDBField relation modifier tests", () => { describe("TailorDBField hooks modifier tests", () => { test("hooks modifier does not affect output type", () => { - const _hookType = db.type("Test", { + const _hookType = db.table("Test", { name: db.string().hooks({ create: () => "created", update: () => "updated", @@ -720,19 +729,49 @@ describe("TailorDBField hooks modifier tests", () => { test("hooks modifier on string field receives string", () => { const _hooks = db.string().hooks; - expectTypeOf[0]>().toEqualTypeOf>(); + expectTypeOf[0]>().toEqualTypeOf>(); }); test("hooks modifier on optional field receives null", () => { const _hooks = db.string({ optional: true }).hooks; - expectTypeOf[0]>().toEqualTypeOf>(); + expectTypeOf[0]>().toEqualTypeOf>(); + }); + + test("create hook args omit oldValue, update hook args include it", () => { + db.string().hooks({ + create: (args) => { + expectTypeOf(args).toEqualTypeOf<{ + input: string | null; + invoker: TailorPrincipal | null; + now: Date; + }>(); + return args.input ?? "default"; + }, + update: (args) => { + expectTypeOf(args).toEqualTypeOf<{ + input: string | null; + oldValue: string; + invoker: TailorPrincipal | null; + now: Date; + }>(); + return args.oldValue; + }, + }); + }); + + test("create hook allows nullable return when default is set", () => { + db.string() + .default("fallback") + .hooks({ + create: ({ input }) => input?.trim(), + }); }); }); describe("TailorDBField validate modifier tests", () => { test("validate modifier does not affect type", () => { - const _validateType = db.type("Test", { - email: db.string().validate(() => true), + const _validateType = db.table("Test", { + email: db.string().validate(() => undefined), }); expectTypeOf>().toEqualTypeOf<{ id: string; @@ -740,45 +779,39 @@ describe("TailorDBField validate modifier tests", () => { }>(); }); - test("validate modifier can receive object with message", () => { - const _validateType = db.type("Test", { - email: db.string().validate([({ value }) => value.includes("@"), "Email must contain @"]), + test("validate modifier can receive function returning error message", () => { + const _validateType = db.table("Test", { + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Email must contain @" : undefined)), }); expectTypeOf>().toEqualTypeOf<{ id: string; email: string; }>(); - // Validate that the validation is stored correctly in metadata const fieldMetadata = _validateType.fields.email.metadata; expect(fieldMetadata.validate).toBeDefined(); expect(fieldMetadata.validate).toHaveLength(1); - // Error message is part of the tuple [fn, message] - expect(fieldMetadata.validate?.[0]).toEqual([expect.any(Function), "Email must contain @"]); }); test("validate modifier can receive multiple validators", () => { - const _validateType = db.type("Test", { - password: db - .string() - .validate( - ({ value }) => value.length >= 8, - [({ value }) => /[A-Z]/.test(value), "Password must contain uppercase letter"], - ), + const _validateType = db.table("Test", { + password: db.string().validate( + ({ value }) => (value.length < 8 ? "Password must be at least 8 characters" : undefined), + ({ value }) => + !/[A-Z]/.test(value) ? "Password must contain uppercase letter" : undefined, + ), }); const fieldMetadata = _validateType.fields.password.metadata; expect(fieldMetadata.validate).toHaveLength(2); - // Second validator is a tuple [fn, errorMessage] - expect((fieldMetadata.validate?.[1] as [unknown, string])[1]).toBe( - "Password must contain uppercase letter", - ); }); test("calling validate modifier more than once causes type error", () => { - const validated = db.string().validate(() => true); + const validated = db.string().validate(() => undefined); // @ts-expect-error validate() cannot be called after validate() has already been called - validated.validate(() => true); + validated.validate(() => undefined); }); test("validate modifier on string field receives string", () => { @@ -875,7 +908,7 @@ describe("TailorDBField unique modifier tests", () => { describe("TailorDBType withTimestamps option tests", () => { test("withTimestamps: true adds timestamp fields", () => { - const _timestampType = db.type("TestWithTimestamp", { + const _timestampType = db.table("TestWithTimestamp", { name: db.string(), ...db.fields.timestamps(), }); @@ -883,39 +916,57 @@ describe("TailorDBType withTimestamps option tests", () => { id: string; name: string; createdAt: string | Date; - updatedAt?: string | Date | null; + updatedAt: string | Date; }>(); }); - const timestampHookUser = unauthenticatedTailorUser; - - test("createdAt create hook respects a user-specified value", () => { + test("createdAt uses default('now') without hooks", () => { const { createdAt } = db.fields.timestamps(); - const createHook = createdAt.metadata.hooks?.create; - expect(createHook).toBeDefined(); + expect(createdAt.metadata.default).toBe("now"); + expect(createdAt.metadata.hooks).toBeUndefined(); + }); + + test("updatedAt uses default('now') for create", () => { + const { updatedAt } = db.fields.timestamps(); + expect(updatedAt.metadata.default).toBe("now"); + expect(updatedAt.metadata.hooks?.create).toBeUndefined(); + }); + + test("updatedAt update hook respects a user-specified value", () => { + const { updatedAt } = db.fields.timestamps(); + const updateHook = updatedAt.metadata.hooks?.update; + expect(updateHook).toBeDefined(); const specified = new Date("2025-02-10T09:00:00Z"); - const result = createHook!({ value: specified, data: {}, user: timestampHookUser }); + const now = new Date("2025-06-01T12:00:00Z"); + const result = updateHook!({ + input: specified, + oldValue: new Date("2025-01-01T00:00:00Z"), + invoker: null, + now, + }); expect(result).toBe(specified); }); - test("createdAt create hook falls back to now when no value is given", () => { - const { createdAt } = db.fields.timestamps(); - const createHook = createdAt.metadata.hooks?.create; - expect(createHook).toBeDefined(); + test("updatedAt update hook falls back to now when no value is given", () => { + const { updatedAt } = db.fields.timestamps(); + const updateHook = updatedAt.metadata.hooks?.update; + expect(updateHook).toBeDefined(); - const before = Date.now(); - const result = createHook!({ value: null, data: {}, user: timestampHookUser }); - const after = Date.now(); - expect(result).toBeInstanceOf(Date); - expect((result as Date).getTime()).toBeGreaterThanOrEqual(before); - expect((result as Date).getTime()).toBeLessThanOrEqual(after); + const now = new Date("2025-06-01T12:00:00Z"); + const result = updateHook!({ + input: null, + oldValue: new Date("2025-01-01T00:00:00Z"), + invoker: null, + now, + }); + expect(result).toBe(now); }); }); describe("TailorDBType composite type tests", () => { test("type with multiple fields works correctly", () => { - const _complexType = db.type("User", { + const _complexType = db.table("User", { name: db.string(), email: db.string(), age: db.int({ optional: true }), @@ -945,7 +996,7 @@ describe("TailorDBType composite type tests", () => { describe("TailorDBType edge case tests", () => { test("type with single field works correctly", () => { - const _singleFieldType = db.type("Simple", { + const _singleFieldType = db.table("Simple", { value: db.string(), }); expectTypeOf>().toEqualTypeOf<{ @@ -955,7 +1006,7 @@ describe("TailorDBType edge case tests", () => { }); test("type with all optional fields works correctly", () => { - const _allOptionalType = db.type("Optional", { + const _allOptionalType = db.table("Optional", { a: db.string({ optional: true }), b: db.int({ optional: true }), c: db.bool({ optional: true }), @@ -969,7 +1020,7 @@ describe("TailorDBType edge case tests", () => { }); test("type with all array fields works correctly", () => { - const _allArrayType = db.type("Array", { + const _allArrayType = db.table("Array", { strings: db.string({ array: true }), numbers: db.int({ array: true }), booleans: db.bool({ array: true }), @@ -985,11 +1036,11 @@ describe("TailorDBType edge case tests", () => { describe("TailorDBType type consistency tests", () => { test("same definition generates same type", () => { - const _type1 = db.type("Same", { + const _type1 = db.table("Same", { name: db.string(), age: db.int(), }); - const _type2 = db.type("Same", { + const _type2 = db.table("Same", { name: db.string(), age: db.int(), }); @@ -997,7 +1048,7 @@ describe("TailorDBType type consistency tests", () => { }); test("id field is automatically added", () => { - const _typeWithoutId = db.type("Test", { + const _typeWithoutId = db.table("Test", { name: db.string(), }); expectTypeOf>().toEqualTypeOf<{ @@ -1009,7 +1060,7 @@ describe("TailorDBType type consistency tests", () => { describe("TailorDBType self relation tests", () => { test("when toward.type is self, rawRelation stores the config (processing happens in parser layer)", () => { - const TestType = db.type("TestType", { + const TestType = db.table("TestType", { name: db.string(), parentID: db.uuid().relation({ type: "n-1", @@ -1045,7 +1096,7 @@ describe("TailorDBType self relation tests", () => { }); test("when backward is not specified, undefined is stored in rawRelation (inflection happens in parser layer)", () => { - const A = db.type("Node", { + const A = db.table("Node", { // Many-to-one (non-unique): backward is plural (nodes) parentID: db.uuid().relation({ type: "n-1", toward: { type: "self" } }), // One-to-one (unique): backward is singular (node) @@ -1062,7 +1113,7 @@ describe("TailorDBType self relation tests", () => { describe("TailorDBType plural form tests", () => { test("when defining type with single name, pluralForm is not set in configure (inflection is executed at parser layer)", () => { - const _userType = db.type("User", { + const _userType = db.table("User", { name: db.string(), }); @@ -1070,7 +1121,7 @@ describe("TailorDBType plural form tests", () => { }); test("when specifying name and plural form as tuple, pluralForm is set", () => { - const _personType = db.type(["Person", "People"], { + const _personType = db.table(["Person", "People"], { name: db.string(), }); @@ -1078,7 +1129,7 @@ describe("TailorDBType plural form tests", () => { }); test("when plural form is empty string, it is not set in configure (inflection is executed at parser layer)", () => { - const _dataType = db.type(["Datum", ""], { + const _dataType = db.table(["Datum", ""], { value: db.string(), }); @@ -1086,7 +1137,7 @@ describe("TailorDBType plural form tests", () => { }); test("error when plural form is same as name (when explicitly specified in tuple format)", () => { - expect(() => db.type(["Data", "Data"], {})).toThrowError( + expect(() => db.table(["Data", "Data"], {})).toThrowError( "The name and the plural form must be different. name=Data", ); }); @@ -1097,7 +1148,7 @@ describe("TailorDBType plural form tests", () => { ["Item", "100Items"], ["Data", "DataSet"], ])("plural form %s/%s can be set via tuple format", (name, pluralForm) => { - const _type = db.type([name, pluralForm], { + const _type = db.table([name, pluralForm], { value: db.string(), }); @@ -1105,7 +1156,7 @@ describe("TailorDBType plural form tests", () => { }); test("all existing features work correctly with tuple format", () => { - const _postType = db.type(["Post", "Posts"], { + const _postType = db.table(["Post", "Posts"], { title: db.string(), content: db.string({ optional: true }), ...db.fields.timestamps(), @@ -1116,7 +1167,7 @@ describe("TailorDBType plural form tests", () => { title: string; content?: string | null; createdAt: string | Date; - updatedAt?: string | Date | null; + updatedAt: string | Date; }>(); expect(_postType.name).toBe("Post"); @@ -1124,31 +1175,29 @@ describe("TailorDBType plural form tests", () => { }); test("validation and plural form coexist in tuple format", () => { - const _userType = db - .type(["User", "Users"], { - name: db.string(), - email: db.string(), - }) - .validate({ - name: [({ value }) => value.length > 0], - email: [({ value }) => value.includes("@"), "Invalid email format"], - }); + const _userType = db.table(["User", "Users"], { + name: db + .string() + .validate(({ value }) => (value.length <= 0 ? "Name must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Invalid email format" : undefined)), + }); expect(_userType.name).toBe("User"); expect(_userType.metadata.settings?.pluralForm).toBe("Users"); - // Validate that the validation function is stored correctly in metadata const emailMetadata = _userType.fields.email.metadata; expect(emailMetadata.validate).toBeDefined(); expect(emailMetadata.validate).toHaveLength(1); }); test("plural form works correctly for types with relations", () => { - const _categoryType = db.type(["Category", "Categories"], { + const _categoryType = db.table(["Category", "Categories"], { name: db.string(), }); - const _productType = db.type(["Product", "Products"], { + const _productType = db.table(["Product", "Products"], { name: db.string(), categoryId: db.uuid().relation({ type: "oneToOne", @@ -1164,14 +1213,12 @@ describe("TailorDBType plural form tests", () => { describe("TailorDBType hooks modifier tests", () => { test("hooks modifier does not affect output type", () => { const _hookType = db - .type("Test", { + .table("Test", { name: db.string(), }) .hooks({ - name: { - create: () => "created", - update: () => "updated", - }, + create: ({ input }) => ({ name: `${input.name}_created` }), + update: ({ input }) => ({ name: `${input.name}_updated` }), }); expectTypeOf>().toEqualTypeOf<{ id: string; @@ -1179,159 +1226,189 @@ describe("TailorDBType hooks modifier tests", () => { }>(); }); - test("setting hooks on id causes type error", () => { - db.type("Test", { + test("type hook stores create/update functions in metadata", () => { + const createFn = () => ({ name: "created" }); + const updateFn = () => ({ name: "updated" }); + const hookType = db.table("Test", { name: db.string() }).hooks({ + create: createFn, + update: updateFn, + }); + expect(hookType.metadata.typeHook?.create).toBe(createFn); + expect(hookType.metadata.typeHook?.update).toBe(updateFn); + }); + + test("type hook return type excludes id", () => { + db.table("Test", { name: db.string() }).hooks({ + // @ts-expect-error id cannot be returned from type hook + create: () => ({ id: "00000000-0000-0000-0000-000000000001" }), + }); + }); + + test("type create hook input: required fields are non-nullable, optional fields are nullable", () => { + db.table("Test", { name: db.string(), age: db.int({ optional: true }) }).hooks({ + create: ({ input, invoker, now }) => { + expectTypeOf(input.name).toEqualTypeOf(); + expectTypeOf(input.age).toEqualTypeOf(); + expectTypeOf(invoker).toBeNullable(); + expectTypeOf(now).toEqualTypeOf(); + return {}; + }, + }); + }); + + test("type create hook input: field with .default() is also non-nullable", () => { + db.table("Test", { name: db.string(), + status: db.string().default("active"), }).hooks({ - // @ts-expect-error hooks() cannot be called on the "id" field - id: { - create: () => "created", + create: ({ input }) => { + expectTypeOf(input.name).toEqualTypeOf(); + expectTypeOf(input.status).toEqualTypeOf(); + return {}; }, }); }); - test("setting hooks on nested field causes type error", () => { - db.type("Test", { - name: db.object({ - first: db.string(), - last: db.string(), - }), - // @ts-expect-error hooks() cannot be called on nested fields - }).hooks({ - name: { - create: () => "created", + test("type update hook: input is partial (nullable), oldRecord is full record", () => { + db.table("Test", { name: db.string(), age: db.int({ optional: true }) }).hooks({ + update: ({ input, oldRecord, invoker, now }) => { + expectTypeOf(input.name).toEqualTypeOf(); + expectTypeOf(input.age).toEqualTypeOf(); + expectTypeOf(oldRecord.name).toEqualTypeOf(); + expectTypeOf(oldRecord.age).toEqualTypeOf(); + expectTypeOf(invoker).toBeNullable(); + expectTypeOf(now).toEqualTypeOf(); + return {}; }, }); }); +}); - test("hooks modifier on string field receives string", () => { - const testType = db.type("Test", { name: db.string() }); - const _hooks = testType.hooks; - type ExpectedHooksParam = Parameters[0]; - type ActualNameType = Exclude; +describe("TailorDBType type-level validate (function form) tests", () => { + test("newRecord has non-nullable required fields and nullable optional fields", () => { + db.table("Test", { + name: db.string(), + email: db.string(), + phone: db.string({ optional: true }), + }).validate(({ newRecord, oldRecord }, issues) => { + expectTypeOf(newRecord.name).toEqualTypeOf(); + expectTypeOf(newRecord.email).toEqualTypeOf(); + expectTypeOf(newRecord.phone).toEqualTypeOf(); + expectTypeOf(oldRecord).toEqualTypeOf | null>(); + if (newRecord.name.length > 100) issues("name", "Name too long"); + }); + }); - expectTypeOf().toEqualTypeOf< - Hook< - { - id: string; - readonly name: string; - }, - string - > - >(); + test("issues function only accepts valid field paths", () => { + db.table("Test", { + name: db.string(), + email: db.string(), + }).validate(({ newRecord: _newRecord }, issues) => { + issues("name", "ok"); + issues("email", "ok"); + // @ts-expect-error TS2345 — "nonexistent" is not a valid field path + issues("nonexistent", "bad"); + }); }); - test("hooks modifier on optional field receives null", () => { - const testType = db.type("Test", { - name: db.string({ optional: true }), + test("issues function accepts dotted paths for nested fields", () => { + db.table("Test", { + profile: db.object({ + displayName: db.string(), + email: db.string(), + }), + }).validate((_args, issues) => { + issues("profile", "ok"); + issues("profile.displayName", "ok"); + issues("profile.email", "ok"); + // @ts-expect-error TS2345 — "profile.nonexistent" is not a valid nested path + issues("profile.nonexistent", "bad"); }); - const _hooks = testType.hooks; - type ExpectedHooksParam = Parameters[0]; - type ActualNameType = Exclude; + }); - expectTypeOf().toEqualTypeOf< - Hook< + test("issues function accepts indexed paths for nested array fields", () => { + db.table("Test", { + items: db.object( { - id: string; - name?: string | null; + name: db.string(), + qty: db.int(), }, - string | null - > - >(); - }); -}); - -describe("TailorDBType validate modifier tests", () => { - test("validate modifier can receive function", () => { - const _validateType = db - .type("Test", { - email: db.string(), - }) - .validate({ - email: () => true, + { array: true }, + ), + }).validate(({ newRecord }, issues) => { + issues("items", "ok"); + newRecord.items.forEach((item, i) => { + if (!item.name) { + issues(`items[${i}].name`, "required"); + } + if (item.qty < 0) { + issues(`items[${i}].qty`, "must be positive"); + } }); + // @ts-expect-error TS2345 — "items.name" (without index) is not valid for array fields + issues("items.name", "bad"); + // @ts-expect-error TS2345 — "items.length" should not be a valid path + issues("items.length", "bad"); + }); + }); + + test("table type built inside a generic factory stays assignable when threaded through another generic factory's explicitly-parameterized signature", () => { + function defineItemModule>(params: { + fields: CustomFields; + }) { + return { + item: db.table("Item", { + unitId: db.string(), + ...params.fields, + }), + }; + } - expectTypeOf>().toEqualTypeOf<{ - id: string; - email: string; - }>(); - const fieldMetadata = _validateType.fields.email.metadata; - expect(fieldMetadata.validate).toHaveLength(1); - }); + const itemModule = defineItemModule({ + fields: { customField: db.string({ optional: true }).description("test") }, + }); - test("validate modifier can receive object with message", () => { - const _validateType = db - .type("Test", { - email: db.string(), - }) - .validate({ - email: [({ value }) => value.includes("@"), "Email must contain @"], - }); + function defineProductModule>(params: { + item: ReturnType>["item"]; + }) { + return params; + } - const fieldMetadata = _validateType.fields.email.metadata; - expect(fieldMetadata.validate).toHaveLength(1); - // Validator is a tuple [fn, errorMessage] - expect((fieldMetadata.validate?.[0] as [unknown, string])[1]).toBe("Email must contain @"); + defineProductModule({ item: itemModule.item }); }); - test("validate modifier can receive multiple validators", () => { - const _validateType = db - .type("Test", { - password: db.string(), + test("type-level validate function stores in metadata", () => { + const type = db + .table("Test", { + name: db.string(), }) - .validate({ - password: [ - ({ value }) => value.length >= 8, - [({ value }) => /[A-Z]/.test(value), "Password must contain uppercase letter"], - ], - }); + .validate((_args, _issues) => {}); - const fieldMetadata = _validateType.fields.password.metadata; - expect(fieldMetadata.validate).toHaveLength(2); - // Second validator is a tuple [fn, errorMessage] - expect((fieldMetadata.validate?.[1] as [unknown, string])[1]).toBe( - "Password must contain uppercase letter", - ); - }); - - test("type error occurs when validate is already set on TailorDBField", () => { - db.type("Test", { - name: db.string().validate(() => true), - // @ts-expect-error validate() cannot be called after validate() has already been called - }).validate({ - name: () => true, - }); + expect(type.metadata.typeValidate).toBeDefined(); }); - test("setting validate on id causes type error", () => { - db.type("Test", { + test("type-level validate receives newRecord and oldRecord with correct types", () => { + db.table("Test", { name: db.string(), - }).validate({ - // @ts-expect-error validate() cannot be called on the "id" field - id: () => true, + age: db.int({ optional: true }), + }).validate(({ newRecord, oldRecord }) => { + expectTypeOf(newRecord.name).toEqualTypeOf(); + expectTypeOf(newRecord.age).toEqualTypeOf(); + if (oldRecord) { + expectTypeOf(oldRecord.name).toEqualTypeOf(); + } }); }); - - test("validate modifier on string field receives string", () => { - const _validate = db.type("Test", { name: db.string() }).validate; - expectTypeOf>().toExtend< - Parameters[0]["name"] - >(); - }); - - test("validate modifier on optional field receives null", () => { - const _validate = db.type("Test", { - name: db.string({ optional: true }), - }).validate; - expectTypeOf>().toExtend< - Parameters[0]["name"] - >(); - }); }); describe("db.object tests", () => { test("correctly infers basic object type", () => { - const _objectType = db.type("Test", { + const _objectType = db.table("Test", { user: db.object({ name: db.string(), age: db.int(), @@ -1356,8 +1433,32 @@ describe("db.object tests", () => { }); }); + test("hooks on inner fields of db.object causes type error", () => { + db.object({ + name: db.string(), + // @ts-expect-error hooks on nested inner fields are not allowed + stamped: db.string().hooks({ + create: ({ input }) => input ?? "default", + }), + }); + }); + + test("default on inner fields of db.object causes type error", () => { + db.object({ + // @ts-expect-error default on nested inner fields are not allowed + name: db.string().default("unnamed"), + status: db.string(), + }); + }); + + test("validate on inner fields of db.object is allowed", () => { + db.object({ + status: db.string().validate(({ value }) => (value.length > 0 ? undefined : "required")), + }); + }); + test("correctly infers object type with optional fields", () => { - const _objectType = db.type("Test", { + const _objectType = db.table("Test", { user: db.object({ name: db.string(), age: db.int({ optional: true }), @@ -1375,7 +1476,7 @@ describe("db.object tests", () => { }); test("correctly infers object type with optional option", () => { - const _objectType = db.type("Test", { + const _objectType = db.table("Test", { user: db.object( { name: db.string(), @@ -1394,7 +1495,7 @@ describe("db.object tests", () => { }); test("correctly infers object type with array option", () => { - const _objectType = db.type("Test", { + const _objectType = db.table("Test", { users: db.object( { name: db.string(), @@ -1413,7 +1514,7 @@ describe("db.object tests", () => { }); test("correctly infers object type with array fields", () => { - const _objectType = db.type("Test", { + const _objectType = db.table("Test", { user: db.object({ name: db.string(), tags: db.string({ array: true }), @@ -1431,7 +1532,7 @@ describe("db.object tests", () => { }); test("correctly infers object type with multiple modifiers", () => { - const _objectType = db.type("Test", { + const _objectType = db.table("Test", { optionalUsers: db.object( { name: db.string(), @@ -1454,7 +1555,7 @@ describe("db.object tests", () => { }); test("correctly infers object type with bool type", () => { - const _objectType = db.type("Test", { + const _objectType = db.table("Test", { settings: db.object({ enabled: db.bool(), push: db.bool({ optional: true }), @@ -1470,7 +1571,7 @@ describe("db.object tests", () => { }); test("correctly infers object type with float and enum types", () => { - const _objectType = db.type("Test", { + const _objectType = db.table("Test", { product: db.object({ name: db.string(), price: db.float(), @@ -1499,11 +1600,34 @@ describe("TailorField/TailorType compatibility tests", () => { name: string; }>(); }); + + test("t.object treats .default() fields as optional", () => { + const obj = t.object({ + name: db.string(), + score: db.int().default(0), + }); + expectTypeOf>().toEqualTypeOf<{ + name: string; + score?: number; + }>(); + }); + + test("t.object with omitFields respects .default()", () => { + const Order = db.table("Order", { + totalPrice: db.int().default(0), + quantity: db.int(), + }); + const input = t.object(Order.omitFields(["id"])); + expectTypeOf>().toEqualTypeOf<{ + quantity: number; + totalPrice?: number; + }>(); + }); }); describe("TailorDBType/TailorDBField description support", () => { test("TailorDBField supports description", () => { - const userType = db.type("User", { + const userType = db.table("User", { name: db.string().description("User name"), age: db.int().description("User age"), }); @@ -1513,7 +1637,7 @@ describe("TailorDBType/TailorDBField description support", () => { }); test("TailorDBType description is set via second argument", () => { - const userType = db.type("User", "User profile type", { + const userType = db.table("User", "User profile type", { name: db.string(), }); @@ -1521,7 +1645,7 @@ describe("TailorDBType/TailorDBField description support", () => { }); test("TailorDBField nested object supports description", () => { - const profileType = db.type("Profile", { + const profileType = db.table("Profile", { userInfo: db .object({ name: db.string().description("Full name"), @@ -1536,7 +1660,7 @@ describe("TailorDBType/TailorDBField description support", () => { }); test("TailorDBType can be used in resolver with description preserved", () => { - const userType = db.type("User", "User type for resolver", { + const userType = db.table("User", "User type for resolver", { name: db.string().description("User name"), email: db.string().description("User email"), }); @@ -1570,7 +1694,7 @@ describe("TailorDBField fluent API type preservation", () => { .string() .description("Email address") .index() - .validate(({ value }) => value.includes("@")); + .validate(({ value }) => (!value.includes("@") ? "Invalid email" : undefined)); expectTypeOf>().toEqualTypeOf(); }); @@ -1580,7 +1704,7 @@ describe("TailorDBField fluent API type preservation", () => { }); test("relation() preserves uuid type", () => { - const User = db.type("User", { name: db.string() }); + const User = db.table("User", { name: db.string() }); const _field = db .uuid() .description("User reference") @@ -1592,7 +1716,7 @@ describe("TailorDBField fluent API type preservation", () => { describe("TailorDBType files method tests", () => { test("files method adds file fields to metadata", () => { const userType = db - .type("User", { + .table("User", { name: db.string(), }) .files({ @@ -1607,7 +1731,7 @@ describe("TailorDBType files method tests", () => { }); test("files field names cannot conflict with existing field names (type error)", () => { - const _userType = db.type("User", { + const _userType = db.table("User", { name: db.string(), avatar: db.string(), // existing field }); @@ -1625,7 +1749,7 @@ describe("TailorDBType files method tests", () => { }); test("files field names that do not conflict are allowed", () => { - const _userType = db.type("User", { + const _userType = db.table("User", { name: db.string(), }); @@ -1637,7 +1761,7 @@ describe("TailorDBType files method tests", () => { }); describe("TailorDBField runtime validation tests", () => { - const user: TailorUser = { + const invoker: TailorPrincipal = { id: "test", type: "user", workspaceId: "workspace-test", @@ -1656,41 +1780,41 @@ describe("TailorDBField runtime validation tests", () => { test("validates string field values", () => { const field = db.string(); - expectParsedValue(field.parse({ value: "hello", data, user }), "hello"); + expectParsedValue(field.parse({ value: "hello", data, invoker }), "hello"); - const bad = field.parse({ value: 123, data, user }); + const bad = field.parse({ value: 123, data, invoker }); expect(bad.issues?.[0]?.message).toBe("Expected a string: received 123"); }); test("validates enum values", () => { const field = db.enum(["active", "inactive"]); - expectParsedValue(field.parse({ value: "active", data, user }), "active"); + expectParsedValue(field.parse({ value: "active", data, invoker }), "active"); - const bad = field.parse({ value: "unknown", data, user }); + const bad = field.parse({ value: "unknown", data, invoker }); expect(bad.issues?.[0]?.message).toBe("Must be one of [active, inactive]: received unknown"); }); test("validates integer values", () => { const field = db.int(); - expectParsedValue(field.parse({ value: 42, data, user }), 42); + expectParsedValue(field.parse({ value: 42, data, invoker }), 42); - const bad = field.parse({ value: "not-a-number", data, user }); + const bad = field.parse({ value: "not-a-number", data, invoker }); expect(bad.issues?.[0]?.message).toBe("Expected an integer: received not-a-number"); }); test("validates float values", () => { const field = db.float(); - expectParsedValue(field.parse({ value: 3.14, data, user }), 3.14); + expectParsedValue(field.parse({ value: 3.14, data, invoker }), 3.14); - const bad = field.parse({ value: "not-a-number", data, user }); + const bad = field.parse({ value: "not-a-number", data, invoker }); expect(bad.issues?.[0]?.message).toBe("Expected a number: received not-a-number"); }); test("validates boolean values", () => { const field = db.bool(); - expectParsedValue(field.parse({ value: true, data, user }), true); + expectParsedValue(field.parse({ value: true, data, invoker }), true); - const bad = field.parse({ value: "true", data, user }); + const bad = field.parse({ value: "true", data, invoker }); expect(bad.issues?.[0]?.message).toBe("Expected a boolean: received true"); }); @@ -1699,37 +1823,37 @@ describe("TailorDBField runtime validation tests", () => { name: db.string(), age: db.int({ optional: true }), }); - expectParsedValue(field.parse({ value: { name: "test", age: 30 }, data, user }), { + expectParsedValue(field.parse({ value: { name: "test", age: 30 }, data, invoker }), { name: "test", age: 30, }); - const bad = field.parse({ value: { name: 123 }, data, user }); + const bad = field.parse({ value: { name: 123 }, data, invoker }); expect(bad.issues?.[0]?.path).toEqual(["name"]); expect(bad.issues?.[0]?.message).toBe("Expected a string: received 123"); }); test("validates array values", () => { const field = db.int({ array: true }); - expectParsedValue(field.parse({ value: [1, 2, 3], data, user }), [1, 2, 3]); + expectParsedValue(field.parse({ value: [1, 2, 3], data, invoker }), [1, 2, 3]); }); test("validates UUID format", () => { const field = db.uuid(); expectParsedValue( - field.parse({ value: "123e4567-e89b-12d3-a456-426614174000", data, user }), + field.parse({ value: "123e4567-e89b-12d3-a456-426614174000", data, invoker }), "123e4567-e89b-12d3-a456-426614174000", ); - const bad = field.parse({ value: "not-a-uuid", data, user }); + const bad = field.parse({ value: "not-a-uuid", data, invoker }); expect(bad.issues?.[0]?.message).toBe("Expected a valid UUID: received not-a-uuid"); }); test("validates date format", () => { const field = db.date(); - expectParsedValue(field.parse({ value: "2025-01-01", data, user }), "2025-01-01"); + expectParsedValue(field.parse({ value: "2025-01-01", data, invoker }), "2025-01-01"); - const bad = field.parse({ value: "2025/01/01", data, user }); + const bad = field.parse({ value: "2025/01/01", data, invoker }); expect(bad.issues?.[0]?.message).toBe( 'Expected to match "yyyy-MM-dd" format: received 2025/01/01', ); @@ -1737,26 +1861,26 @@ describe("TailorDBField runtime validation tests", () => { test("validates time format", () => { const field = db.time(); - expectParsedValue(field.parse({ value: "10:11", data, user }), "10:11"); + expectParsedValue(field.parse({ value: "10:11", data, invoker }), "10:11"); - const bad = field.parse({ value: "10:11:12", data, user }); + const bad = field.parse({ value: "10:11:12", data, invoker }); expect(bad.issues?.[0]?.message).toBe('Expected to match "HH:mm" format: received 10:11:12'); }); test("validates required and optional handling", () => { const requiredField = db.string(); - const requiredMissing = requiredField.parse({ value: undefined, data, user }); + const requiredMissing = requiredField.parse({ value: undefined, data, invoker }); expect(requiredMissing.issues?.[0]?.message).toBe("Required field is missing"); const optionalField = db.string({ optional: true }); - expectParsedValue(optionalField.parse({ value: undefined, data, user }), null); + expectParsedValue(optionalField.parse({ value: undefined, data, invoker }), null); }); }); describe("TailorDBType gqlOperations tests", () => { test("gqlOperations stores raw config via features()", () => { const orderType = db - .type("Order", { + .table("Order", { name: db.string(), }) .features({ @@ -1772,7 +1896,7 @@ describe("TailorDBType gqlOperations tests", () => { test("gqlOperations stores multiple operations config", () => { const archiveType = db - .type("Archive", { + .table("Archive", { data: db.string(), }) .features({ @@ -1789,7 +1913,7 @@ describe("TailorDBType gqlOperations tests", () => { test("gqlOperations stores read config", () => { const secretType = db - .type("Secret", { + .table("Secret", { value: db.string(), }) .features({ @@ -1804,7 +1928,7 @@ describe("TailorDBType gqlOperations tests", () => { test("gqlOperations works with other features", () => { const logType = db - .type("Log", { + .table("Log", { message: db.string(), }) .features({ @@ -1824,7 +1948,7 @@ describe("TailorDBType gqlOperations tests", () => { describe("TailorDBType gqlOperations alias tests", () => { test("gqlOperations: 'query' stores alias as raw value", () => { const readOnlyType = db - .type("ReadOnly", { + .table("ReadOnly", { data: db.string(), }) .features({ @@ -1838,7 +1962,7 @@ describe("TailorDBType gqlOperations alias tests", () => { test("gqlOperations: 'query' works with other features", () => { const auditType = db - .type("Audit", { + .table("Audit", { action: db.string(), }) .features({ @@ -1866,7 +1990,9 @@ describe("TailorDBField immutability", () => { test("field.validate() returns a new field without mutating the original", () => { const original = db.string(); - const withValidate = original.validate(({ value }) => value.length > 0); + const withValidate = original.validate(({ value }) => + value.length <= 0 ? "Must not be empty" : undefined, + ); expect(withValidate).not.toBe(original); expect(original.metadata.validate).toBeUndefined(); @@ -1919,7 +2045,7 @@ describe("TailorDBField immutability", () => { }); test("field.relation() returns a new field without mutating the original", () => { - const User = db.type("User", { name: db.string() }); + const User = db.table("User", { name: db.string() }); const original = db.uuid(); const withRelation = original.relation({ type: "n-1", toward: { type: User } }); @@ -1942,24 +2068,26 @@ describe("TailorDBField immutability", () => { }); describe("TailorDBType does not mutate shared fields", () => { - test("type.hooks() does not mutate the shared field", () => { + test("type.hooks() does not affect other types sharing the same field", () => { const sharedField = db.string(); - const typeA = db.type("TypeA", { name: sharedField }).hooks({ name: { create: () => "A" } }); - const typeB = db.type("TypeB", { name: sharedField }); + const typeA = db.table("TypeA", { name: sharedField }).hooks({ create: () => ({ name: "A" }) }); + const typeB = db.table("TypeB", { name: sharedField }); - expect(typeA.fields.name.metadata.hooks).toBeDefined(); - expect(typeB.fields.name.metadata.hooks).toBeUndefined(); + expect(typeA.metadata.typeHook?.create).toBeDefined(); + expect(typeB.metadata.typeHook).toBeUndefined(); expect(sharedField.metadata.hooks).toBeUndefined(); }); test("type.validate() does not mutate the shared field", () => { const sharedField = db.string(); - const typeA = db - .type("TypeA", { email: sharedField }) - .validate({ email: ({ value }) => value.includes("@") }); - const typeB = db.type("TypeB", { email: sharedField }); + const typeA = db.table("TypeA", { + email: sharedField.validate(({ value }) => + !value.includes("@") ? "Invalid email" : undefined, + ), + }); + const typeB = db.table("TypeB", { email: sharedField }); expect(typeA.fields.email.metadata.validate).toBeDefined(); expect(typeB.fields.email.metadata.validate).toBeUndefined(); @@ -1970,9 +2098,8 @@ describe("TailorDBType does not mutate shared fields", () => { const nameField = db.string(); const fields = { name: nameField }; - db.type("TypeA", fields).hooks({ name: { create: () => "hooked" } }); + db.table("TypeA", fields).hooks({ create: () => ({ name: "hooked" }) }); - // The fields record should still reference the original field instance expect(fields.name).toBe(nameField); }); @@ -1980,9 +2107,12 @@ describe("TailorDBType does not mutate shared fields", () => { const emailField = db.string(); const fields = { email: emailField }; - db.type("TypeA", fields).validate({ email: ({ value }) => value.includes("@") }); + db.table("TypeA", { + email: emailField.validate(({ value }) => + !value.includes("@") ? "Invalid email" : undefined, + ), + }); - // The fields record should still reference the original field instance expect(fields.email).toBe(emailField); }); }); @@ -2025,6 +2155,23 @@ describe("TailorDBField clone tests", () => { expectTypeOf>().toEqualTypeOf(); }); + test("clone preserves enum value output type", () => { + const original = db.enum(["active", "inactive"], { array: true }); + const cloned = original.clone(); + + expect(cloned.metadata.array).toBe(true); + expectTypeOf>().toEqualTypeOf<("active" | "inactive")[]>(); + }); + + test("clone preserves existing optional output when overriding array", () => { + const original = db.object({ name: db.string() }, { optional: true }); + const cloned = original.clone({ array: true }); + + expect(cloned.metadata.required).toBe(false); + expect(cloned.metadata.array).toBe(true); + expectTypeOf>().toEqualTypeOf<{ name: string }[] | null>(); + }); + test("clone with both optional and array overrides", () => { const original = db.string(); const cloned = original.clone({ optional: true, array: true }); @@ -2034,6 +2181,18 @@ describe("TailorDBField clone tests", () => { expectTypeOf>().toEqualTypeOf(); }); + test("pickFields with options preserves field output base type", () => { + const User = db.table("User", { + role: db.enum(["admin", "member"]), + profile: db.object({ name: db.string() }, { optional: true }), + }); + + const picked = User.pickFields(["role", "profile"], { array: true }); + + expectTypeOf>().toEqualTypeOf<("admin" | "member")[]>(); + expectTypeOf>().toEqualTypeOf<{ name: string }[] | null>(); + }); + test("clones unique modifier correctly", () => { const original = db.string().unique(); const cloned = original.clone(); @@ -2043,7 +2202,7 @@ describe("TailorDBField clone tests", () => { }); test("clones relation config correctly", () => { - const User = db.type("User", { name: db.string() }); + const User = db.table("User", { name: db.string() }); const original = db.uuid().relation({ type: "n-1", toward: { type: User, as: "author" }, @@ -2075,7 +2234,8 @@ describe("TailorDBField clone tests", () => { }); test("clones validate correctly", () => { - const validator = ({ value }: { value: string }) => value.length > 0; + const validator = ({ value }: { value: string }) => + value.length <= 0 ? "Must not be empty" : undefined; const original = db.string().validate(validator); const cloned = original.clone(); @@ -2087,14 +2247,18 @@ describe("TailorDBField clone tests", () => { }); test("rejects changing the array shape of a validated field", () => { - const scalar = db.string().validate(({ value }) => value.length > 0); - const array = db.string({ array: true }).validate(({ value }) => value.length > 0); - const type = db.type("ValidatedClone", { value: scalar }); - const mixedType = db.type("MixedValidatedClone", { + const scalar = db + .string() + .validate(({ value }) => (value.length > 0 ? undefined : "must not be empty")); + const array = db + .string({ array: true }) + .validate(({ value }) => (value.length > 0 ? undefined : "must not be empty")); + const type = db.table("ValidatedClone", { value: scalar }); + const mixedType = db.table("MixedValidatedClone", { validated: scalar, plain: db.string(), }); - const arrayType = db.type("ValidatedArrayClone", { value: array }); + const arrayType = db.table("ValidatedArrayClone", { value: array }); type ScalarCloneOptions = Parameters[0]; type ArrayCloneOptions = Parameters[0]; @@ -2128,20 +2292,6 @@ describe("TailorDBField clone tests", () => { expect(() => arrayClone({ array: false })).toThrowError(expectedError); }); - test("clones validate with tuple format correctly", () => { - const validator = ({ value }: { value: string }) => value.length > 0; - const original = db.string().validate([validator, "Value must not be empty"]); - const cloned = original.clone(); - - expect(cloned.metadata.validate).toBeDefined(); - expect(cloned.metadata.validate).toHaveLength(1); - expect(cloned.metadata.validate?.[0]).toEqual([validator, "Value must not be empty"]); - - // Verify deep copy (different reference for array and tuple) - expect(cloned.metadata.validate).not.toBe(original.metadata.validate); - expect(cloned.metadata.validate?.[0]).not.toBe(original.metadata.validate?.[0]); - }); - test("clones serial config correctly", () => { const original = db.int().serial({ start: 100 }); const cloned = original.clone(); @@ -2193,7 +2343,7 @@ describe("TailorDBField clone tests", () => { describe("TailorDBField decimal type tests", () => { test("decimal field outputs string type correctly", () => { - const _decimalType = db.type("Test", { + const _decimalType = db.table("Test", { price: db.decimal(), }); expectTypeOf>().toEqualTypeOf<{ @@ -2203,7 +2353,7 @@ describe("TailorDBField decimal type tests", () => { }); test("optional decimal field outputs string | null type correctly", () => { - const _decimalType = db.type("Test", { + const _decimalType = db.table("Test", { discount: db.decimal({ optional: true }), }); expectTypeOf>().toEqualTypeOf<{ @@ -2246,16 +2396,28 @@ describe("TailorDBField decimal type tests", () => { "-1.5e10", ])("decimal parse validates valid decimal string %s", (value) => { const field = db.decimal(); - const user = { id: "test", _loggedIn: true } as unknown as TailorUser; - expect(field.parse({ value, data: {}, user })).toEqual({ value }); + const invoker: TailorPrincipal = { + id: "test", + type: "user", + workspaceId: "workspace-test", + attributes: {}, + attributeList: [], + }; + expect(field.parse({ value, data: {}, invoker })).toEqual({ value }); }); test.each(["abc", 123, "", "1_000_000", "0b1.1p-5", "1e", "e5", "."])( "decimal parse rejects invalid decimal string %s", (value) => { const field = db.decimal(); - const user = { id: "test", _loggedIn: true } as unknown as TailorUser; - expect(field.parse({ value, data: {}, user })).toHaveProperty("issues"); + const invoker: TailorPrincipal = { + id: "test", + type: "user", + workspaceId: "workspace-test", + attributes: {}, + attributeList: [], + }; + expect(field.parse({ value, data: {}, invoker })).toHaveProperty("issues"); }, ); }); diff --git a/packages/sdk/src/configure/services/tailordb/schema.ts b/packages/sdk/src/configure/services/tailordb/schema.ts index 50c10fc94..e9b6fc68b 100644 --- a/packages/sdk/src/configure/services/tailordb/schema.ts +++ b/packages/sdk/src/configure/services/tailordb/schema.ts @@ -27,15 +27,21 @@ import type { TailorFieldType, TailorToTs, FieldValidateInput, - ValidateConfig, - Validators, } from "#/configure/types/field.types"; import type { PluginAttachment, PluginConfigs } from "#/plugin/types"; -import type { InferredAttributeMap } from "#/runtime/types"; +import type { InferredAttributes } from "#/runtime/types"; import type { output, InferFieldsOutput, TypeLevelError } from "#/types/helpers"; import type { RawPermissions } from "#/types/tailordb.generated"; import type { TailorTypeGqlPermission, TailorTypePermission } from "./permission"; -import type { Hook, Hooks, ExcludeNestedDBFields, TypeFeatures } from "./types"; +import type { + Hook, + TypeHook, + ExcludeNestedDBFields, + ExcludeHookedDBFields, + ExcludeDefaultedDBFields, + TypeFeatures, + TypeValidateFn, +} from "./types"; import type { StandardSchemaV1 } from "@standard-schema/spec"; // Erased DB fields stay assignable across builder method-state changes. @@ -55,6 +61,7 @@ export type TailorAnyDBField = Omit< index: AnyBuilderMethod; unique: AnyBuilderMethod; vector: AnyBuilderMethod; + default: AnyBuilderMethod; hooks: AnyBuilderMethod; validate: AnyBuilderMethod; serial: AnyBuilderMethod; @@ -89,6 +96,7 @@ type WithDBFieldHooks = Defined & { }; serial: false; }; +type WithDBFieldDefault = Defined & { default: true }; type WithDBFieldValidate = Defined & { validate: true }; type WithDBFieldSerial = Defined & { serial: true; @@ -98,8 +106,29 @@ type WithDBFieldCloneOptions extends true ? Defined : Omit & { - array: NewOpt extends { array: true } ? true : Defined["array"]; + array: NewOpt extends { array: true } + ? true + : NewOpt extends { array: false } + ? false + : Defined["array"]; }; +type NonNullableDBFieldOutput = Exclude; +type DBFieldScalarOutput = + NonNullableDBFieldOutput extends (infer Item)[] ? Item : NonNullableDBFieldOutput; +type DBFieldCloneArrayOutput = NewOpt extends { + array: true; +} + ? DBFieldScalarOutput[] + : NewOpt extends { array: false } + ? DBFieldScalarOutput + : NonNullableDBFieldOutput; +type DBFieldCloneOutput = NewOpt extends { optional: true } + ? DBFieldCloneArrayOutput | null + : NewOpt extends { optional: false } + ? DBFieldCloneArrayOutput + : null extends Output + ? DBFieldCloneArrayOutput | null + : DBFieldCloneArrayOutput; type DBFieldCloneOptions = Omit & { array?: Defined extends { validate: unknown } ? Defined["array"] : boolean; }; @@ -192,7 +221,10 @@ type DBFieldVectorFn = () => Tai Output >; type DBFieldHooksFn = < - const H extends Hook, + const H extends Hook< + Output, + Defined extends { default: true } ? Output | null | undefined : Output + >, >( hooks: H, ) => TailorDBField, Output>; @@ -282,6 +314,22 @@ type DBFieldSerialMethod = : Defined extends { type: "integer" | "string"; array: false } ? DBFieldSerialFn : TypeLevelError<"serial can only be set on non-array integer or string fields">; +type DBFieldDefaultFn = ( + value: Output extends null ? NonNullable : Output, +) => TailorDBField, Output>; +type DBFieldDefaultMethod = + IsAny extends true + ? DBFieldDefaultFn + : Defined extends { default: unknown } + ? TypeLevelError<".default() has already been set"> + : Defined extends { type: "nested" } + ? TypeLevelError<"default cannot be set on nested type fields"> + : Defined extends { serial: true } + ? TypeLevelError<"default cannot be set on serial fields"> + : null extends Output + ? TypeLevelError<"default cannot be set on optional fields"> + : DBFieldDefaultFn; + /** * Full TailorDBField interface with builder methods. * Extends the minimal structural interface from types/ with fluent API methods. @@ -330,6 +378,15 @@ export interface TailorDBField< */ vector: DBFieldVectorMethod; + /** + * Set a default value for the field on create. When the field is required, + * this makes it optional in the Create input — the default fills in when + * no value (or a nullish hook result) is provided. + * + * For datetime/date/time fields, pass `"now"` to use the operation timestamp. + */ + default: DBFieldDefaultMethod; + /** * Add hooks for create/update operations on this field. */ @@ -355,10 +412,7 @@ export interface TailorDBField< */ clone>( options?: NewOpt, - ): TailorDBField< - WithDBFieldCloneOptions, - FieldOutput - >; + ): TailorDBField, DBFieldCloneOutput>; } /** @@ -368,25 +422,25 @@ export interface TailorDBField< export interface TailorDBType< // oxlint-disable-next-line no-explicit-any Fields extends Record = any, - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, // oxlint-disable-next-line no-explicit-any Defined extends DefinedDBTypeMetadata = any, > extends TailorDBTypeBase { _description?: string; hooks( - hooks: DBTypeDuplicateInputGuard< + hook: DBTypeDuplicateInputGuard< Defined, "hooks", - Hooks, + TypeHook, ".hooks() has already been set" >, ): TailorDBType>; validate( - validators: DBTypeDuplicateInputGuard< + fn: DBTypeDuplicateInputGuard< Defined, "validate", - Validators, + TypeValidateFn, ".validate() has already been set" >, ): TailorDBType>; @@ -450,8 +504,8 @@ export interface TailorDBType< keys: K[], options: Opt & DBFieldsCloneOptionsGuard, ): { - [P in K]: Fields[P] extends TailorDBField - ? TailorDBField, FieldOutput> + [P in K]: Fields[P] extends TailorDBField + ? TailorDBField, DBFieldCloneOutput> : never; }; omitFields(keys: K[]): Omit; @@ -463,7 +517,7 @@ export interface TailorDBType< export type TailorDBInstance< // oxlint-disable-next-line no-explicit-any Fields extends Record = any, - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, // oxlint-disable-next-line no-explicit-any Defined extends DefinedDBTypeMetadata = any, > = TailorDBType; @@ -527,7 +581,8 @@ type TailorDBFieldRuntime = Omit index(): object; unique(): object; vector(): object; - hooks(hooks: Hook): object; + default(value: unknown): object; + hooks(hooks: Hook): object; serial(config: SerialConfig): object; clone(options?: FieldOptions): TailorDBFieldRuntime; parse(args: FieldParseArgs): StandardSchemaV1.Result; @@ -633,7 +688,7 @@ function createTailorDBFieldRuntime< return parseInternal({ value: args.value, data: args.data, - user: args.user, + invoker: args.invoker, pathArray: [], }); }, @@ -666,7 +721,13 @@ function createTailorDBFieldRuntime< return cloneWith({ vector: true }); }, - hooks(hooks: Hook) { + // oxlint-disable-next-line no-explicit-any + default(value: any) { + // oxlint-disable-next-line no-explicit-any + return cloneWith({ default: value }) as any; + }, + + hooks(hooks: Hook) { return cloneWith({ hooks }); }, @@ -821,7 +882,7 @@ function date(options?: Opt) { /** * Create a datetime field (date and time). - * Format: ISO 8601 "yyyy-MM-ddTHH:mm:ssZ" + * Format: ISO 8601, such as "yyyy-MM-ddTHH:mm:ssZ" or "yyyy-MM-ddTHH:mm:ss+09:00" * @param options - Field configuration options * @returns A datetime field * @example db.datetime() @@ -868,7 +929,10 @@ function _enum( * @example db.object({ name: db.string() }, { optional: true }) */ function object< - const F extends Record & ExcludeNestedDBFields, + const F extends Record & + ExcludeNestedDBFields & + ExcludeHookedDBFields & + ExcludeDefaultedDBFields, const Opt extends FieldOptions, >(fields: F, options?: Opt) { return createField("nested", options, fields) as unknown as TailorDBField< @@ -889,7 +953,7 @@ function object< function createTailorDBType< // oxlint-disable-next-line no-explicit-any const Fields extends Record = any, - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, >( name: string, fields: Fields, @@ -901,6 +965,10 @@ function createTailorDBType< const _permissions: RawPermissions = {}; let _files: Record = {}; const _plugins: PluginAttachment[] = []; + // oxlint-disable-next-line typescript/no-unsafe-function-type + let _typeHook: { create?: Function; update?: Function } | undefined; + // oxlint-disable-next-line typescript/no-unsafe-function-type + let _typeValidate: Function | undefined; const _definedMethods = new Set(); if (options.description !== undefined) { _definedMethods.add("description"); @@ -958,49 +1026,21 @@ function createTailorDBType< permissions: _permissions, files: _files, ...(Object.keys(indexes).length > 0 && { indexes }), + ...(_typeHook && { typeHook: _typeHook }), + ...(_typeValidate && { typeValidate: _typeValidate }), }; }, - hooks(hooks: Hooks): TypeAfter<"hooks"> { + hooks(hook: TypeHook): TypeAfter<"hooks"> { return runMethodOnce("hooks", () => { - // `Hooks` is strongly typed, but `Object.entries()` loses that information. - // oxlint-disable-next-line no-explicit-any - Object.entries(hooks).forEach(([fieldName, fieldHooks]: [string, any]) => { - const field = this.fields[fieldName]; - if (field === undefined) throw new Error(`field not found: ${fieldName}`); - (this.fields as Record)[fieldName] = ( - field as TailorAnyDBField - ).hooks(fieldHooks); - }); + _typeHook = hook; return this as TypeAfter<"hooks">; }); }, - validate(validators: Validators): TypeAfter<"validate"> { + validate(fn: TypeValidateFn): TypeAfter<"validate"> { return runMethodOnce("validate", () => { - Object.entries(validators).forEach(([fieldName, fieldValidators]) => { - const field = this.fields[fieldName] as TailorAnyDBField; - - const validators = fieldValidators as - | FieldValidateInput - | FieldValidateInput[]; - - const isValidateConfig = (v: unknown): v is ValidateConfig => { - return Array.isArray(v) && v.length === 2 && typeof v[1] === "string"; - }; - - let updatedField: TailorAnyDBField; - if (Array.isArray(validators)) { - if (isValidateConfig(validators)) { - updatedField = field.validate(validators); - } else { - updatedField = field.validate(...validators); - } - } else { - updatedField = field.validate(validators); - } - (this.fields as Record)[fieldName] = updatedField; - }); + _typeValidate = fn; return this as TypeAfter<"validate">; }); }, @@ -1108,19 +1148,19 @@ function createTailorDBType< const idField = uuid(); type idField = typeof idField; -type DBType< +type DBTable< F extends { id?: never } & Record, Defined extends DefinedDBTypeMetadata = DefinedDBTypeMetadata, -> = TailorDBInstance<{ id: idField } & F, InferredAttributeMap, Defined>; +> = TailorDBInstance<{ id: idField } & F, InferredAttributes, Defined>; /** - * Creates a new database type with the specified fields. - * An `id` field (UUID) is automatically added to every type. - * @param name - The name of the type, or a tuple of [name, pluralForm] - * @param fields - The field definitions for the type - * @returns A new TailorDBType instance + * Creates a new database table with the specified fields. + * An `id` field (UUID) is automatically added to every table. + * @param name - The name of the table, or a tuple of [name, pluralForm] + * @param fields - The field definitions for the table + * @returns A new TailorDB table instance * @example - * export const user = db.type("User", { + * export const user = db.table("User", { * name: db.string(), * email: db.string(), * age: db.int({ optional: true }), @@ -1130,28 +1170,28 @@ type DBType< * // Always export both the value and type: * export type user = typeof user; */ -function dbType>( +function dbTable>( name: string | [string, string], fields: F, -): DBType; +): DBTable; /** - * Creates a new database type with the specified fields and description. - * An `id` field (UUID) is automatically added to every type. - * @param name - The name of the type, or a tuple of [name, pluralForm] - * @param description - A description of the type - * @param fields - The field definitions for the type - * @returns A new TailorDBType instance + * Creates a new database table with the specified fields and description. + * An `id` field (UUID) is automatically added to every table. + * @param name - The name of the table, or a tuple of [name, pluralForm] + * @param description - A description of the table + * @param fields - The field definitions for the table + * @returns A new TailorDB table instance */ -function dbType>( +function dbTable>( name: string | [string, string], description: string, fields: F, -): DBType>; -function dbType>( +): DBTable>; +function dbTable>( name: string | [string, string], fieldsOrDescription: string | F, fields?: F, -): DBType | DBType> { +): DBTable | DBTable> { const typeName = Array.isArray(name) ? name[0] : name; const pluralForm = Array.isArray(name) ? name[1] : undefined; @@ -1170,12 +1210,12 @@ function dbType; + ) as DBTable; } -/** TailorDB schema builder utilities for defining types and fields. */ +/** TailorDB schema builder utilities for defining tables and fields. */ export const db = { - type: dbType, + table: dbTable, uuid, string, bool, @@ -1189,24 +1229,22 @@ export const db = { object, fields: { /** - * Creates standard timestamp fields (createdAt, updatedAt) with auto-hooks. - * createdAt is set on create, updatedAt is set on update. - * A user-specified createdAt is respected when provided (e.g. seeding historical - * records); the current time is used only when the value is omitted. + * Creates standard timestamp fields (createdAt, updatedAt) with automatic defaults. + * Both fields default to the current time on create. updatedAt is also refreshed on update. + * User-specified values are respected when provided (e.g. seeding historical records). * @returns An object with createdAt and updatedAt fields * @example - * const model = db.type("Model", { + * const model = db.table("Model", { * name: db.string(), * ...db.fields.timestamps(), * }); */ timestamps: () => ({ - createdAt: datetime() - .hooks({ create: ({ value }) => value ?? new Date() }) - .description("Record creation timestamp"), - updatedAt: datetime({ optional: true }) - .hooks({ update: () => new Date() }) - .description("Record last update timestamp"), + createdAt: datetime().default("now").description("Record creation timestamp"), + updatedAt: datetime() + .default("now") + .hooks({ update: ({ input, now }) => input ?? now }) + .description("Record update timestamp"), }), }, }; diff --git a/packages/sdk/src/configure/services/tailordb/types.ts b/packages/sdk/src/configure/services/tailordb/types.ts index 6cc086c4e..16a5be936 100644 --- a/packages/sdk/src/configure/services/tailordb/types.ts +++ b/packages/sdk/src/configure/services/tailordb/types.ts @@ -6,16 +6,16 @@ import type { DefinedFieldMetadata, FieldMetadata, TailorField, + TailorFieldType, } from "#/configure/types/field.types"; -import type { InferredAttributeMap, TailorUser } from "#/runtime/types"; -import type { InferFieldsOutput, output, Prettify } from "#/types/helpers"; +import type { InferredAttributes, TailorPrincipal } from "#/runtime/types"; +import type { DeepReadonly, InferFieldsOutput, output, Prettify } from "#/types/helpers"; import type { DBFieldMetadata as DBFieldMetadataGenerated, GqlOperationsInput, RawPermissions, TailorDBServiceConfigInput, } from "#/types/tailordb.generated"; -import type { NonEmptyObject } from "type-fest"; export type SerialConfig = Prettify< { @@ -40,6 +40,7 @@ export interface DBFieldMetadata extends FieldMetadata { serial?: SerialConfig; relation?: boolean; scale?: number; + default?: unknown; } export interface DefinedDBFieldMetadata extends DefinedFieldMetadata { @@ -55,6 +56,7 @@ export interface DefinedDBFieldMetadata extends DefinedFieldMetadata { }; serial?: boolean; relation?: boolean; + default?: boolean; } export type GqlOperationsConfig = GqlOperationsInput; @@ -88,6 +90,10 @@ export interface TailorDBTypeMetadata { unique?: boolean; } >; + // oxlint-disable-next-line typescript/no-unsafe-function-type + typeHook?: { create?: Function; update?: Function }; + // oxlint-disable-next-line typescript/no-unsafe-function-type + typeValidate?: Function; } /** @@ -121,7 +127,7 @@ export interface TailorDBType< Fields extends Record = any, // Generic parameter kept for compatibility with full TailorDBType in configure/ // oxlint-disable-next-line no-unused-vars - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, > { readonly name: string; readonly fields: Fields; @@ -135,39 +141,90 @@ export interface TailorDBType< export type TailorAnyDBType = TailorDBType; export type TailorDBInstance< - // Default kept loose for convenience; callers still get fully inferred types from `db.type()`. + // Default kept loose for convenience; callers still get fully inferred types from `db.table()`. // oxlint-disable-next-line no-explicit-any Fields extends Record = any, - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, > = TailorDBType; // --- Hook types (UX-focused, for configure layer) --- -type HookFn = (args: { - value: TValue; - data: TData extends Record - ? { readonly [K in keyof TData]?: TData[K] | null | undefined } +type HookArgs = + TData extends Record + ? { readonly [K in keyof TData]?: DeepReadonly | null | undefined } : unknown; - user: TailorUser; + +type CreateHookFn = (args: { + input: TValue; + invoker: TailorPrincipal | null; + now: Date; +}) => TReturn; + +type UpdateHookFn = (args: { + input: TValue; + oldValue: TReturn; + invoker: TailorPrincipal | null; + now: Date; }) => TReturn; -export type Hook = { - create?: HookFn; - update?: HookFn; +export type Hook = { + create?: CreateHookFn; + update?: UpdateHookFn; }; -export type Hooks< +type DotJoin = A extends "" ? B : `${A}.${B}`; + +type DottedPaths = string extends keyof T + ? string + : T extends readonly (infer E)[] + ? E extends Record + ? { + [K in keyof E & string]: + | `${Prefix}[${number}].${K}` + | DottedPaths, `${Prefix}[${number}].${K}`>; + }[keyof E & string] + : never + : T extends Record + ? { + [K in keyof T & string]: + | DotJoin + | DottedPaths, DotJoin>; + }[keyof T & string] + : never; + +export type TypeValidateFn< F extends Record, TData = { [K in keyof F]: output }, -> = NonEmptyObject<{ - [K in Exclude as F[K]["_defined"] extends { - hooks: unknown; - } - ? never - : F[K]["_defined"] extends { type: "nested" } - ? never - : K]?: Hook>; -}>; +> = ( + args: { + newRecord: DeepReadonly; + oldRecord: DeepReadonly | null; + invoker: TailorPrincipal | null; + }, + issues: (field: DottedPaths>, message: string) => void, +) => void; + +type TypeCreateHookFn< + F extends Record, + TData = { [K in keyof F]: output }, +> = (args: { input: DeepReadonly; invoker: TailorPrincipal | null; now: Date }) => { + [K in Exclude]?: TData[K] | null; +}; + +type TypeUpdateHookFn< + F extends Record, + TData = { [K in keyof F]: output }, +> = (args: { + input: HookArgs; + oldRecord: DeepReadonly; + invoker: TailorPrincipal | null; + now: Date; +}) => { [K in Exclude]?: TData[K] | null }; + +export type TypeHook> = { + create?: TypeCreateHookFn; + update?: TypeUpdateHookFn; +}; // --- Field helper types --- @@ -179,6 +236,31 @@ export type ExcludeNestedDBFields> = : T[K]; }; +// oxlint-disable no-explicit-any -- conditional type matching requires `any` for the output param +export type ExcludeHookedDBFields> = { + [K in keyof T]: T[K] extends TailorDBField< + { type: TailorFieldType; array: boolean; hooks: { create: true; update: boolean } }, + any + > + ? never + : T[K] extends TailorDBField< + { type: TailorFieldType; array: boolean; hooks: { create: boolean; update: true } }, + any + > + ? never + : T[K]; +}; + +export type ExcludeDefaultedDBFields> = { + [K in keyof T]: T[K] extends TailorDBField< + { type: TailorFieldType; array: boolean; default: true }, + any + > + ? never + : T[K]; +}; +// oxlint-enable no-explicit-any + // --- Type features --- export interface TypeFeatures { diff --git a/packages/sdk/src/configure/services/workflow/job.test.ts b/packages/sdk/src/configure/services/workflow/job.test.ts index c3d4df209..ec29e202a 100644 --- a/packages/sdk/src/configure/services/workflow/job.test.ts +++ b/packages/sdk/src/configure/services/workflow/job.test.ts @@ -1,21 +1,59 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. import { describe, expect, test, expectTypeOf } from "vitest"; +import { platformSerialize } from "#/utils/test/platform-serialize"; import { createWorkflowJob, type WorkflowJob } from "./job"; +import { getRegisteredJob } from "./registry"; +import { buildJobContext } from "./test-env-key"; import { createWorkflow } from "./workflow"; -import type { TailorInvoker } from "#/runtime/types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { TypeLevelError } from "#/types/helpers"; type WorkflowJobConfig = Parameters< typeof createWorkflowJob >[0]; +async function withRegisteredJobRuntime(run: () => Promise): Promise { + const root = globalThis as { + tailor?: { + workflow?: { + startJobFunction: (name: string, args?: unknown) => unknown; + }; + }; + }; + const previousTailor = root.tailor; + + root.tailor = { + ...previousTailor, + workflow: { + startJobFunction: (name, args) => { + const body = getRegisteredJob(name); + if (!body) return null; + const out = body(platformSerialize(args), buildJobContext()); + return out instanceof Promise + ? out.then((value) => platformSerialize(value)) + : platformSerialize(out); + }, + }, + }; + + try { + return await run(); + } finally { + if (previousTailor) { + root.tailor = previousTailor; + } else { + delete root.tailor; + } + } +} + describe("WorkflowJob type inference", () => { test("preserves literal types in output when using as const", () => { const _job = createWorkflowJob({ name: "test", body: () => ({ status: "ok" as const, count: 42 }), }); - type Output = Awaited>; + type Output = ReturnType; expectTypeOf().toEqualTypeOf<{ status: "ok"; count: number }>(); }); @@ -24,7 +62,7 @@ describe("WorkflowJob type inference", () => { name: "test", body: (input: { type: "a" | "b" }) => ({ result: input.type }), }); - type Input = Parameters[0]; + type Input = Parameters[0]; expectTypeOf().toEqualTypeOf<{ type: "a" | "b" }>(); }); @@ -37,7 +75,7 @@ describe("WorkflowJob type inference", () => { name: "test", body: (input: UserInput) => ({ greeting: `Hello ${input.name}` }), }); - type Input = Parameters[0]; + type Input = Parameters[0]; expectTypeOf().toEqualTypeOf(); }); @@ -50,7 +88,7 @@ describe("WorkflowJob type inference", () => { name: "test", body: (): UserOutput => ({ id: "123", created: true }), }); - type Output = Awaited>; + type Output = ReturnType; expectTypeOf().toEqualTypeOf(); }); @@ -60,10 +98,144 @@ describe("WorkflowJob type inference", () => { body: (_input: undefined, context) => { expectTypeOf(context).toHaveProperty("env"); expectTypeOf(context).toHaveProperty("invoker"); - expectTypeOf(context.invoker).toEqualTypeOf(); + expectTypeOf(context.invoker).toEqualTypeOf(); }, }); }); + + test("direct body calls work when process.getBuiltinModule is unavailable", async () => { + const descriptor = Object.getOwnPropertyDescriptor(process, "getBuiltinModule"); + Object.defineProperty(process, "getBuiltinModule", { + configurable: true, + value: undefined, + }); + try { + const invoker: TailorPrincipal = { + id: "principal-1", + type: "user", + workspaceId: "workspace-1", + attributes: {}, + attributeList: [], + }; + const child = createWorkflowJob({ + name: "capture-child-invoker-without-get-builtin-module", + body: (_input: undefined, context) => context.invoker?.id ?? "anonymous", + }); + const parent = createWorkflowJob({ + name: "propagate-parent-invoker-without-get-builtin-module", + body: async () => await child.start(), + }); + + await withRegisteredJobRuntime(async () => { + await expect(parent.body(undefined, { env: {}, invoker })).resolves.toBe("principal-1"); + }); + } finally { + if (descriptor) { + Object.defineProperty(process, "getBuiltinModule", descriptor); + } else { + delete (process as { getBuiltinModule?: unknown }).getBuiltinModule; + } + } + }); + + test("direct body calls propagate invoker to started child jobs", async () => { + const invoker: TailorPrincipal = { + id: "principal-1", + type: "user", + workspaceId: "workspace-1", + attributes: { role: "ADMIN" }, + attributeList: [], + }; + const child = createWorkflowJob({ + name: "capture-child-invoker", + body: (_input: undefined, context) => context.invoker?.id ?? "anonymous", + }); + const parent = createWorkflowJob({ + name: "propagate-parent-invoker", + body: async () => await child.start(), + }); + + await withRegisteredJobRuntime(async () => { + await expect(parent.body(undefined, { env: {}, invoker })).resolves.toBe("principal-1"); + }); + }); + + test("concurrent direct body calls isolate invokers for child starts", async () => { + const firstInvoker: TailorPrincipal = { + id: "principal-1", + type: "user", + workspaceId: "workspace-1", + attributes: {}, + attributeList: [], + }; + const secondInvoker: TailorPrincipal = { + id: "principal-2", + type: "machine_user", + workspaceId: "workspace-1", + attributes: {}, + attributeList: [], + }; + let releaseFirst: () => void = () => {}; + let releaseSecond: () => void = () => {}; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const secondGate = new Promise((resolve) => { + releaseSecond = resolve; + }); + const gates = { + first: firstGate, + second: secondGate, + }; + const child = createWorkflowJob({ + name: "capture-concurrent-child-invoker", + body: (_input: undefined, context) => context.invoker?.id ?? "anonymous", + }); + const parent = createWorkflowJob({ + name: "capture-concurrent-parent-invoker", + body: async (input: { gate: "first" | "second" }) => { + await gates[input.gate]; + return await child.start(); + }, + }); + + await withRegisteredJobRuntime(async () => { + const first = parent.body({ gate: "first" }, { env: {}, invoker: firstInvoker }); + const second = parent.body({ gate: "second" }, { env: {}, invoker: secondInvoker }); + + releaseFirst(); + await expect(first).resolves.toBe("principal-1"); + releaseSecond(); + await expect(second).resolves.toBe("principal-2"); + }); + }); + + test("start reads the runtime invoker when no body context is active", async () => { + const previousTailor = (globalThis as { tailor?: unknown }).tailor; + (globalThis as { tailor?: unknown }).tailor = { + context: { + getInvoker: () => ({ + id: "runtime-principal", + type: "machine_user", + workspaceId: "workspace-1", + attributes: ["role"], + attributeMap: { role: "SYSTEM" }, + }), + }, + }; + try { + const job = createWorkflowJob({ + name: "capture-runtime-invoker", + body: (_input: undefined, context) => context.invoker?.id ?? "anonymous", + }); + + await withRegisteredJobRuntime(async () => { + expect(job.start()).toBe("runtime-principal"); + }); + } finally { + (globalThis as { tailor?: unknown }).tailor = previousTailor; + } + }); }); describe("WorkflowJob type constraints", () => { @@ -252,13 +424,13 @@ describe("WorkflowJob type constraints", () => { }); }); - describe("trigger return type", () => { + describe("start return type", () => { test("returns Output as-is (no Jsonify transformation)", () => { const job = createWorkflowJob({ name: "test", body: () => ({ result: "ok", count: 42, active: true as boolean }), }); - expectTypeOf(job.trigger).returns.resolves.toEqualTypeOf<{ + expectTypeOf(job.start).returns.toEqualTypeOf<{ result: string; count: number; active: boolean; @@ -275,7 +447,7 @@ describe("WorkflowJob type constraints", () => { }, }), }); - expectTypeOf(job.trigger).returns.resolves.toEqualTypeOf<{ + expectTypeOf(job.start).returns.toEqualTypeOf<{ data: { id: string; tags: string[]; @@ -288,7 +460,7 @@ describe("WorkflowJob type constraints", () => { name: "test", body: () => undefined, }); - expectTypeOf(job.trigger).returns.resolves.toEqualTypeOf(); + expectTypeOf(job.start).returns.toEqualTypeOf(); }); test("returns T | undefined for T | undefined output", () => { @@ -298,27 +470,27 @@ describe("WorkflowJob type constraints", () => { return Math.random() > 0.5 ? { value: 1 } : undefined; }, }); - expectTypeOf(job.trigger).returns.resolves.toEqualTypeOf<{ value: number } | undefined>(); + expectTypeOf(job.start).returns.toEqualTypeOf<{ value: number } | undefined>(); }); }); - describe("input presence affects trigger signature", () => { - test("trigger takes no arguments when input is undefined", () => { + describe("input presence affects start signature", () => { + test("start takes no arguments when input is undefined", () => { const job = createWorkflowJob({ name: "test", body: () => ({ result: "ok" }), }); - const _trigger: () => Promise<{ result: string }> = job.trigger; - expectTypeOf(_trigger).toBeFunction(); + const _start: () => { result: string } = job.start; + expectTypeOf(_start).toBeFunction(); }); - test("trigger requires input when body has input parameter", () => { + test("start requires input when body has input parameter", () => { const job = createWorkflowJob({ name: "test", body: (input: { id: string }) => ({ result: input.id }), }); - const _trigger: (input: { id: string }) => Promise<{ result: string }> = job.trigger; - expectTypeOf(_trigger).toBeFunction(); + const _start: (input: { id: string }) => { result: string } = job.start; + expectTypeOf(_start).toBeFunction(); }); }); @@ -331,10 +503,10 @@ describe("WorkflowJob type constraints", () => { expectTypeOf().toEqualTypeOf<"test">(); }); - test("trigger return preserves Output as-is", () => { + test("start return preserves Output as-is", () => { type Job = WorkflowJob<"test", undefined, { id: string; result: string }>; - expectTypeOf>().resolves.toEqualTypeOf<{ + expectTypeOf>().toEqualTypeOf<{ id: string; result: string; }>(); @@ -409,41 +581,35 @@ describe("WorkflowJob type constraints", () => { }); // Plain `node` environment (no `tailor-runtime`, no `mockWorkflow()`), so -// `.trigger()` exercises the no-shim fallback. -describe("trigger fallback without tailor.workflow", () => { - test("runs the registered job body locally", async () => { +// `.start()` should not execute job bodies locally. +describe("start without tailor.workflow", () => { + test("job start throws instead of running the registered body", () => { const double = createWorkflowJob({ name: "fallback-double", body: (input: { n: number }) => ({ doubled: input.n * 2 }), }); - expect(await double.trigger({ n: 21 })).toEqual({ doubled: 42 }); + expect(() => double.start({ n: 21 })).toThrow(/tailor\.workflow is not available/); }); - test("runs the whole chain via workflow.mainJob.trigger()", async () => { - const inner = createWorkflowJob({ - name: "fallback-inner", - body: (input: { n: number }) => ({ n: input.n + 1 }), - }); + test("workflow start rejects instead of running the main job", async () => { const main = createWorkflowJob({ name: "fallback-main", - body: async (input: { n: number }) => { - const a = await inner.trigger({ n: input.n }); - const b = await inner.trigger({ n: a.n }); - return { total: b.n }; - }, + body: (input: { n: number }) => ({ total: input.n + 1 }), }); const workflow = createWorkflow({ name: "fallback-wf", mainJob: main }); - expect(await workflow.mainJob.trigger({ n: 0 })).toEqual({ total: 2 }); + await expect(workflow.start({ n: 0 })).rejects.toThrow(/tailor\.workflow is not available/); }); - test("enforces the JSON boundary on the fallback path", async () => { - const bad = createWorkflowJob({ - name: "fallback-bad", - body: () => ({ when: new Date() }) as never, + test("workflow start takes no arguments when the main job's input is undefined", async () => { + const main = createWorkflowJob({ + name: "fallback-main-no-input", + body: () => ({ ok: true }), }); + const workflow = createWorkflow({ name: "fallback-wf-no-input", mainJob: main }); - await expect(bad.trigger()).rejects.toThrow(/Date instance/); + // Must type-check with zero arguments, matching WorkflowJob.start. + await expect(workflow.start()).rejects.toThrow(/tailor\.workflow is not available/); }); }); diff --git a/packages/sdk/src/configure/services/workflow/job.ts b/packages/sdk/src/configure/services/workflow/job.ts index 6641f6511..5905372cb 100644 --- a/packages/sdk/src/configure/services/workflow/job.ts +++ b/packages/sdk/src/configure/services/workflow/job.ts @@ -1,7 +1,8 @@ import { brandValue } from "#/utils/brand"; -import { dispatchTriggerJob, registerJob, type RegisteredJobBody } from "./registry"; -import type { TailorEnv, TailorInvoker } from "#/runtime/types"; -import type { TriggerJobFunctionOptions } from "#/runtime/workflow"; +import { dispatchStartJob, registerJob, type RegisteredJobBody } from "./registry"; +import { withWorkflowTestInvoker } from "./test-env-key"; +import type { TailorEnv, TailorPrincipal } from "#/runtime/types"; +import type { StartJobFunctionOptions } from "#/runtime/workflow"; import type { JsonCompatible, TypeLevelError } from "#/types/helpers"; /** @@ -9,7 +10,7 @@ import type { JsonCompatible, TypeLevelError } from "#/types/helpers"; */ export type WorkflowJobContext = { env: TailorEnv; - invoker?: TailorInvoker; + invoker: TailorPrincipal | null; }; /** @@ -32,36 +33,34 @@ type JobBody = [null] extends [I] : TypeLevelError<"Input must be JsonValue-compatible (plain objects/arrays; no class instances or functions)">; /** - * WorkflowJob represents a job that can be triggered in a workflow. + * WorkflowJob represents a job that can be started from a workflow. * * Type constraints: * - Input: Must be JsonValue-compatible (plain objects/arrays; no class instances or functions) or undefined. * - Output: Must be JsonValue-compatible (plain objects/arrays; no class instances or functions), undefined, or void. - * - Trigger returns `Awaited` as-is (no Jsonify transformation). + * - Start returns `Awaited` as-is (no Promise or Jsonify transformation). */ export interface WorkflowJob { name: Name; /** - * Trigger this job with the given input. Returns a Promise that resolves - * to the job's output value. Accepts an optional second argument to pass - * `executionPolicyKey` for platform-side concurrency enforcement. + * Start this job with the given input and return the job's output value. + * Accepts an optional second argument to pass `executionPolicyKey` for + * platform-side concurrency enforcement. * @example * body: async (input) => { - * const a = await jobA.trigger({ id: input.id }); - * const b = await jobB.trigger({ id: input.id }, { + * const a = jobA.start({ id: input.id }); + * const b = jobB.start({ id: input.id }, { * executionPolicyKey: `tenant-api.${input.tenantId}`, * }); * return { a, b }; * } */ - trigger: [Input] extends [undefined] - ? (input?: undefined, options?: TriggerJobFunctionOptions) => Promise> - : (input: Input, options?: TriggerJobFunctionOptions) => Promise>; + start: [Input] extends [undefined] + ? (input?: undefined, options?: StartJobFunctionOptions) => Awaited + : (input: Input, options?: StartJobFunctionOptions) => Awaited; body: (input: Input, context: WorkflowJobContext) => Output | Promise; } -export { WORKFLOW_TEST_ENV_KEY } from "./test-env-key"; - interface CreateWorkflowJobConfig { readonly name: Name; readonly body: JobBody; @@ -78,8 +77,8 @@ interface CreateWorkflowJobConfig { * class instances exposing methods are rejected via the property walk. * @param config - Job configuration with name and body function. * @param config.name - Unique job name across the project. - * @param config.body - Async function that processes the job input. - * @returns A WorkflowJob that can be triggered from other jobs. + * @param config.body - Function that processes the job input. + * @returns A WorkflowJob that can be started from other jobs. * @example * // Simple job with async body: * export const fetchData = createWorkflowJob({ @@ -93,9 +92,9 @@ interface CreateWorkflowJobConfig { * // Orchestrator job that fans out to other jobs. * export const orchestrate = createWorkflowJob({ * name: "orchestrate", - * body: async (input: { orderId: string }) => { - * const inventory = await checkInventory.trigger({ orderId: input.orderId }); - * const payment = await processPayment.trigger({ orderId: input.orderId }); + * body: (input: { orderId: string }) => { + * const inventory = checkInventory.start({ orderId: input.orderId }); + * const payment = processPayment.start({ orderId: input.orderId }); * return { inventory, payment }; * }, * }); @@ -103,35 +102,39 @@ interface CreateWorkflowJobConfig { export function createWorkflowJob( config: CreateWorkflowJobConfig, ): WorkflowJob> { - const body = config.body as (input: I, context: WorkflowJobContext) => O | Promise; + const userBody = config.body as (input: I, context: WorkflowJobContext) => O | Promise; + const body = process.env.__TAILOR_PLATFORM_BUNDLE + ? userBody + : (input: I, context: WorkflowJobContext): O | Promise => + withWorkflowTestInvoker(context.invoker, () => userBody(input, context)); - // Test-only registry/trigger shim; the platform bundle sets the flag so it is DCE'd. - if (!process.env.TAILOR_PLATFORM_BUNDLE) { + // Test-only local runner registry; the platform bundle sets the flag so it is DCE'd. + if (!process.env.__TAILOR_PLATFORM_BUNDLE) { registerJob(config.name, body as RegisteredJobBody); } - const trigger = process.env.TAILOR_PLATFORM_BUNDLE - ? async () => { + const start = process.env.__TAILOR_PLATFORM_BUNDLE + ? () => { throw new Error( - "This workflow job's .trigger() is rewritten at build time and is unavailable in the bundle", + "This workflow job's .start() is rewritten at build time and is unavailable in the bundle", ); } : // Preserve arity: use `arguments.length` (regular function, not arrow) so - // `.trigger(args, undefined)` is treated as "options passed" — matching + // `.start(args, undefined)` is treated as "options passed" — matching // the bundler rewrite, which forwards the literal `undefined` from the // AST as a third argument. Without this, local execution and bundled // workflows would hand mocks different call shapes. - async function trigger(args?: unknown, options?: TriggerJobFunctionOptions) { + function start(args?: unknown, options?: StartJobFunctionOptions) { // oxlint-disable-next-line prefer-rest-params return ( arguments.length >= 2 - ? await dispatchTriggerJob(config.name, args, options) - : await dispatchTriggerJob(config.name, args) + ? dispatchStartJob(config.name, args, options) + : dispatchStartJob(config.name, args) ) as Awaited; }; return brandValue( - { name: config.name, trigger, body } as WorkflowJob>, + { name: config.name, start, body } as WorkflowJob>, "workflow-job", ); } diff --git a/packages/sdk/src/configure/services/workflow/registry.ts b/packages/sdk/src/configure/services/workflow/registry.ts index 651c58318..904ee4452 100644 --- a/packages/sdk/src/configure/services/workflow/registry.ts +++ b/packages/sdk/src/configure/services/workflow/registry.ts @@ -1,6 +1,4 @@ -import { platformSerialize } from "#/utils/test/platform-serialize"; -import { buildJobContext } from "./test-env-key"; -import type { TailorEnv, TailorInvoker } from "#/runtime/types"; +import type { TailorEnv, TailorPrincipal } from "#/runtime/types"; import type { StartJobFunctionOptions, StartWorkflowOptions } from "#/runtime/workflow"; /** @@ -10,30 +8,18 @@ import type { StartJobFunctionOptions, StartWorkflowOptions } from "#/runtime/wo */ export type RegisteredJobBody = ( args: unknown, - context: { env: TailorEnv; invoker?: TailorInvoker }, + context: { env: TailorEnv; invoker: TailorPrincipal | null }, ) => unknown | Promise; -export interface RegisteredWorkflow { - mainJobName: string; -} - const JOB_REGISTRY_KEY: unique symbol = Symbol.for("tailor-platform/sdk:job-registry"); -const WORKFLOW_REGISTRY_KEY: unique symbol = Symbol.for("tailor-platform/sdk:workflow-registry"); type PlatformWorkflow = { startWorkflow: (name: string, args?: unknown, options?: StartWorkflowOptions) => Promise; - triggerWorkflow: ( - name: string, - args?: unknown, - options?: StartWorkflowOptions, - ) => Promise; startJobFunction: (name: string, args?: unknown, options?: StartJobFunctionOptions) => unknown; - triggerJobFunction: (name: string, args?: unknown, options?: StartJobFunctionOptions) => unknown; }; type GlobalWithRegistry = typeof globalThis & { [JOB_REGISTRY_KEY]?: Map; - [WORKFLOW_REGISTRY_KEY]?: Map; tailor?: { workflow?: PlatformWorkflow }; }; @@ -47,22 +33,12 @@ function jobs(): Map { return map; } -function workflows(): Map { - const g = globalThis as GlobalWithRegistry; - let map = g[WORKFLOW_REGISTRY_KEY]; - if (!map) { - map = new Map(); - g[WORKFLOW_REGISTRY_KEY] = map; - } - return map; -} - /** * Register a job body keyed by job name. Called as a side effect by - * `createWorkflowJob` so the vitest mock can execute the body when - * `globalThis.tailor.workflow.triggerJobFunction(name, args)` is invoked. + * `createWorkflowJob` so `runWorkflowLocally()` can execute dependent job + * bodies when `globalThis.tailor.workflow.startJobFunction(name, args)` is invoked. * - * In production builds the bundler rewrites `.trigger()` calls so this registry + * In production builds the bundler rewrites `.start()` calls so this registry * is never read; the gated write is dropped as dead code. * @param name - Job name * @param body - Job body function @@ -80,84 +56,63 @@ export function getRegisteredJob(name: string): RegisteredJobBody | undefined { return jobs().get(name); } -/** - * Register a workflow's main job name so the mock can run the workflow locally. - * @param name - Workflow name - * @param mainJobName - Name of the workflow's main job - */ -export function registerWorkflow(name: string, mainJobName: string): void { - workflows().set(name, { mainJobName }); -} - -/** - * Look up a registered workflow by name. - * @param name - Workflow name - * @returns The registered workflow, or undefined - */ -export function getRegisteredWorkflow(name: string): RegisteredWorkflow | undefined { - return workflows().get(name); -} - function currentPlatformWorkflow(): PlatformWorkflow | undefined { // globalThis may not have the tailor property at runtime // oxlint-disable-next-line typescript/no-unnecessary-condition return (globalThis as GlobalWithRegistry).tailor?.workflow; } -// A valid placeholder UUID, so callers that validate the execution id behave the -// same locally as against the platform. -export const TRIGGER_DEFAULT = "00000000-0000-4000-8000-000000000000"; - -function serializeReturn(out: unknown): unknown { - return out instanceof Promise ? out.then((v) => platformSerialize(v)) : platformSerialize(out); -} - -// Runs the registered body across the platform JSON boundary. Shared by the -// `tailor-runtime` default runner and the no-shim `.trigger()` fallback below. -export function runRegisteredJob(name: string, args?: unknown): unknown { - const body = getRegisteredJob(name); - const out = body ? body(platformSerialize(args), buildJobContext()) : null; - return serializeReturn(out); +function requirePlatformWorkflow(): PlatformWorkflow { + const workflow = currentPlatformWorkflow(); + if (!workflow) { + throw new Error( + "tailor.workflow is not available. Run tests in the `tailor-runtime` Vitest environment and use mockWorkflow(), or use runWorkflowLocally() from @tailor-platform/sdk/vitest for local workflow execution.", + ); + } + return workflow; } -export async function runRegisteredWorkflow(name: string, args?: unknown): Promise { - const workflow = getRegisteredWorkflow(name); - if (workflow) await runRegisteredJob(workflow.mainJobName, args); - return TRIGGER_DEFAULT; -} +// A valid placeholder UUID, so callers that validate the execution id behave the +// same locally as against the platform. +export const START_DEFAULT = "00000000-0000-4000-8000-000000000000"; -// `.trigger()` routes through the installed `tailor.workflow` shim, falling back -// to running the registered body/workflow locally when none is installed. +// `.start()` routes through the installed `tailor.workflow` shim. Local body +// execution is intentionally available only through `runWorkflowLocally()`. // Preserve arity: the shim sees a 2-argument call when the caller supplied no // options, and a 3-argument call otherwise, mirroring the bundler rewrite so // mocks observe the same shape in local execution and in bundled workflows. -export function dispatchTriggerJob( +export function dispatchStartJob( name: string, args?: unknown, options?: StartJobFunctionOptions, ): unknown { - const workflow = currentPlatformWorkflow(); - if (!workflow) return runRegisteredJob(name, args); + const workflow = requirePlatformWorkflow(); // oxlint-disable-next-line prefer-rest-params return arguments.length >= 3 - ? workflow.triggerJobFunction(name, args, options) - : workflow.triggerJobFunction(name, args); + ? workflow.startJobFunction(name, args, options) + : workflow.startJobFunction(name, args); } -// Accepts `unknown` because the SDK-side `.trigger()` accepts a wider options +// Accepts `unknown` because the SDK-side `.start()` accepts a wider options // shape than the platform surface (e.g. `authInvoker` may be a machine-user // name string that the bundler normalizes at build time). Local execution // forwards the value verbatim; only the bundled path enforces the platform // contract. -export function dispatchTriggerWorkflow( +export function dispatchStartWorkflow( name: string, args?: unknown, - options?: unknown, + options?: { invoker?: unknown }, ): Promise { - const workflow = currentPlatformWorkflow(); - if (!workflow) return runRegisteredWorkflow(name, args); + const workflow = requirePlatformWorkflow(); // oxlint-disable-next-line prefer-rest-params - return arguments.length >= 3 - ? workflow.triggerWorkflow(name, args, options as StartWorkflowOptions | undefined) - : workflow.triggerWorkflow(name, args); + if (arguments.length < 3) { + return workflow.startWorkflow(name, args); + } + return workflow.startWorkflow( + name, + args, + options?.invoker === undefined + ? (options as StartWorkflowOptions | undefined) + : ({ authInvoker: options.invoker } as StartWorkflowOptions), + ); } diff --git a/packages/sdk/src/configure/services/workflow/test-env-key.ts b/packages/sdk/src/configure/services/workflow/test-env-key.ts index 1dec2ccca..4dbaa0982 100644 --- a/packages/sdk/src/configure/services/workflow/test-env-key.ts +++ b/packages/sdk/src/configure/services/workflow/test-env-key.ts @@ -1,7 +1,7 @@ /** * Typed accessors for the test-time globalThis slot used to pass `env` from * `mockWorkflow().setEnv()` (in `@tailor-platform/sdk/vitest`) to - * `createWorkflowJob().trigger()` bodies. The slot key is private to this + * `runWorkflowLocally()` job bodies. The slot key is private to this * module; callers go through the get/set/clear functions below so both sides * share the same access path. * @@ -9,10 +9,25 @@ * it from nested Vitest configs that do not resolve `@/` aliases. * @internal */ -import type { TailorEnv, TailorInvoker } from "../../../runtime/types"; +import { AsyncLocalStorage } from "node:async_hooks"; +import type { TailorEnv, TailorPrincipal } from "../../../runtime/types"; const SLOT_KEY = "__tailorWorkflowTestEnv"; +type AsyncLocalStorageLike = { + getStore(): T | undefined; + run(store: T, callback: () => R): R; +}; + +let invokerStorage: AsyncLocalStorageLike | undefined; + +function workflowInvokerStorage(): AsyncLocalStorageLike { + if (!invokerStorage) { + invokerStorage = new AsyncLocalStorage(); + } + return invokerStorage; +} + /** * Read the test-time env slot. * @returns Current env, or `undefined` when unset. @@ -24,7 +39,7 @@ export function readWorkflowTestEnv(): TailorEnv | undefined { /** * Write the test-time env slot. - * @param env - Env value to expose to `.trigger()` bodies. + * @param env - Env value to expose to `runWorkflowLocally()` job bodies. * @internal */ export function writeWorkflowTestEnv(env: TailorEnv): void { @@ -39,33 +54,42 @@ export function clearWorkflowTestEnv(): void { delete (globalThis as unknown as Record)[SLOT_KEY]; } -/** - * Env-var fallback read by `.trigger()` when `mockWorkflow().setEnv()` is unset. - * @deprecated Use `mockWorkflow().setEnv()` from `@tailor-platform/sdk/vitest`. - * @internal - */ -export const WORKFLOW_TEST_ENV_KEY = "TAILOR_TEST_WORKFLOW_ENV"; +export function withWorkflowTestInvoker(invoker: TailorPrincipal | null, run: () => T): T { + return workflowInvokerStorage().run(invoker, run); +} -// env from `mockWorkflow().setEnv()`, else the deprecated env-var. Shallow-copied -// to isolate against cross-trigger mutation. -export function buildJobContext(): { env: TailorEnv; invoker?: TailorInvoker } { +type RuntimeInvoker = { + id: string; + type: "user" | "machine_user"; + workspaceId: string; + attributes?: string[] | TailorPrincipal["attributes"]; + attributeMap?: TailorPrincipal["attributes"]; + attributeList?: TailorPrincipal["attributeList"]; +}; + +function readRuntimeInvoker(): TailorPrincipal | null { + const runtime = ( + globalThis as unknown as { + tailor?: { context?: { getInvoker?: () => RuntimeInvoker | null } }; + } + ).tailor?.context?.getInvoker; + const raw = runtime?.(); + if (!raw) return null; + return { + id: raw.id, + type: raw.type, + workspaceId: raw.workspaceId, + attributes: raw.attributeMap ?? (Array.isArray(raw.attributes) ? {} : (raw.attributes ?? {})), + attributeList: (raw.attributeList ?? + (Array.isArray(raw.attributes) ? raw.attributes : [])) as TailorPrincipal["attributeList"], + }; +} + +// Shallow-copied to isolate against cross-trigger mutation. +export function buildJobContext(): { env: TailorEnv; invoker: TailorPrincipal | null } { + const storedInvoker = invokerStorage?.getStore(); + const invoker = storedInvoker === undefined ? readRuntimeInvoker() : storedInvoker; const fromGlobal = readWorkflowTestEnv(); - if (fromGlobal !== undefined) return { env: { ...fromGlobal } }; - const raw = process.env[WORKFLOW_TEST_ENV_KEY]; - if (!raw) return { env: {} as TailorEnv }; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (cause) { - throw new Error( - `Invalid JSON in ${WORKFLOW_TEST_ENV_KEY}; provide valid JSON or use mockWorkflow().setEnv().`, - { cause }, - ); - } - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error( - `${WORKFLOW_TEST_ENV_KEY} must be a JSON object; provide a record or use mockWorkflow().setEnv().`, - ); - } - return { env: { ...(parsed as TailorEnv) } }; + if (fromGlobal !== undefined) return { env: { ...fromGlobal }, invoker }; + return { env: {} as TailorEnv, invoker }; } diff --git a/packages/sdk/src/configure/services/workflow/wait-point.test.ts b/packages/sdk/src/configure/services/workflow/wait-point.test.ts index 9097f7582..24ec1559f 100644 --- a/packages/sdk/src/configure/services/workflow/wait-point.test.ts +++ b/packages/sdk/src/configure/services/workflow/wait-point.test.ts @@ -1,55 +1,55 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. import { afterEach, describe, expect, test, expectTypeOf } from "vitest"; import { setupWaitPointMock, setupWorkflowMock } from "#/utils/test/mock"; -import { defineWaitPoint, defineWaitPoints } from "./wait-point"; +import { createWaitPoint, createWaitPoints } from "./wait-point"; import type { TailorRuntime } from "#/runtime/index"; import type { TypeLevelError } from "#/types/helpers"; const TailorGlobal = globalThis as { tailor?: TailorRuntime }; -describe("defineWaitPoints", () => { +describe("createWaitPoints", () => { afterEach(() => { delete TailorGlobal.tailor; }); test("invalid definitions expose TypeLevelError messages", () => { - expectTypeOf>>().toEqualTypeOf< + expectTypeOf>>().toEqualTypeOf< TypeLevelError<"Payload cannot be null at the top level"> >(); expectTypeOf< - ReturnType> + ReturnType> >().toEqualTypeOf>(); - expectTypeOf>>().toEqualTypeOf< + expectTypeOf>>().toEqualTypeOf< TypeLevelError<"Result cannot be (or include) undefined (resolve callback must return a value)"> >(); expectTypeOf< - ReturnType> + ReturnType> >().toEqualTypeOf< TypeLevelError<"Result cannot be (or include) undefined (resolve callback must return a value)"> >(); expectTypeOf< - ReturnType> + ReturnType> >().toEqualTypeOf< TypeLevelError<"Result must be JsonValue-compatible (plain objects/arrays; no class instances or functions)"> >(); expectTypeOf< - ReturnType> + ReturnType> >().toEqualTypeOf>(); expectTypeOf< - ReturnType> + ReturnType> >().toEqualTypeOf< TypeLevelError<"Payload must be JsonValue-compatible (plain objects/arrays; no class instances or functions)"> >(); }); test("creates instances with typed wait/resolve", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ approval: define<{ message: string }, { approved: boolean }>(), })); expectTypeOf(wps.approval.wait).toBeFunction(); @@ -57,53 +57,53 @@ describe("defineWaitPoints", () => { }); test("rejects Date in Payload / Result (pure JSON only)", () => { - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - Date is not JsonValue-compatible (Result) check: define(), })); - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - Date is not JsonValue-compatible (Payload) check: define<{ when: Date }, { ok: boolean }>(), })); }); test("rejects top-level null in Payload", () => { - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - null is not allowed at top level (even in union) check: define<{ id: string } | null, { ok: boolean }>(), })); }); test("rejects top-level undefined in Payload union", () => { - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - undefined is not allowed at top level (except when Payload = undefined alone) check: define<{ id: string } | undefined, { ok: boolean }>(), })); }); test("allows Payload = undefined (no-payload convention)", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ check: define(), })); expectTypeOf(wps.check.wait).toBeFunction(); }); test("allows nested null in Payload", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ check: define<{ data: string | null }, { ok: boolean }>(), })); expectTypeOf(wps.check.wait).toBeFunction(); }); test("allows top-level null in Result", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ check: define<{ id: string }, { data: string } | null>(), })); expectTypeOf(wps.check.wait).toBeFunction(); }); test("wait return type is Result as-is (no Jsonify transformation)", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ check: define(), })); type WaitReturn = Awaited>; @@ -111,7 +111,7 @@ describe("defineWaitPoints", () => { }); test("wait omits payload when Payload is undefined", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ signal: define(), })); type Params = Parameters; @@ -119,7 +119,7 @@ describe("defineWaitPoints", () => { }); test("throws without platform API or mock", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ approval: define(), })); expect(() => wps.approval.wait()).toThrow("mockWorkflow"); @@ -127,28 +127,28 @@ describe("defineWaitPoints", () => { test("throws a helpful error when only setupWorkflowMock is active (wait/resolve auto-stubbed)", () => { setupWorkflowMock(() => undefined); - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ approval: define(), })); expect(() => wps.approval.wait()).toThrow("mockWorkflow"); }); test("rejects Result = undefined (callback must return a value)", () => { - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - Result cannot be undefined; platform throws if callback returns undefined check: define(), })); }); test("rejects top-level undefined in Result union", () => { - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - Result cannot include top-level undefined check: define(), })); }); test("allows nested undefined (optional fields) in Result", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ check: define(), })); expectTypeOf(wps.check.wait).toBeFunction(); @@ -159,7 +159,7 @@ describe("defineWaitPoints", () => { onWait: (_key, _payload) => ({ approved: true }), }); - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ approval: define<{ msg: string }, { approved: boolean }>(), })); @@ -176,7 +176,7 @@ describe("defineWaitPoints", () => { }, }); - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ approval: define<{ msg: string }, { ok: boolean }>(), })); @@ -193,7 +193,7 @@ describe("defineWaitPoints", () => { onWait: () => "ok", }); - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ step: define(), })); @@ -202,24 +202,24 @@ describe("defineWaitPoints", () => { }); }); -describe("defineWaitPoint", () => { +describe("createWaitPoint", () => { afterEach(() => { delete TailorGlobal.tailor; }); test("creates a typed instance with the given key", () => { - const wp = defineWaitPoint<{ msg: string }, { ok: boolean }>("my-key"); + const wp = createWaitPoint<{ msg: string }, { ok: boolean }>("my-key"); expectTypeOf(wp.wait).toBeFunction(); expectTypeOf(wp.resolve).toBeFunction(); }); test("throws without platform API or mock", () => { - const wp = defineWaitPoint("my-step"); + const wp = createWaitPoint("my-step"); expect(() => wp.wait()).toThrow("mockWorkflow"); }); test("rejects Result = undefined (callback must return a value)", () => { - const wp = defineWaitPoint("my-step"); + const wp = createWaitPoint("my-step"); // @ts-expect-error - wp resolves to an error string, not WaitPointInstance expectTypeOf(wp.wait).toBeFunction(); }); @@ -229,7 +229,7 @@ describe("defineWaitPoint", () => { onWait: (_key, _payload) => ({ ok: true }), }); - const wp = defineWaitPoint<{ msg: string }, { ok: boolean }>("approval"); + const wp = createWaitPoint<{ msg: string }, { ok: boolean }>("approval"); await wp.wait({ msg: "please" }); expect(waitCalls[0]).toEqual({ key: "approval", payload: { msg: "please" } }); }); @@ -241,7 +241,7 @@ describe("defineWaitPoint", () => { }, }); - const wp = defineWaitPoint("my-step"); + const wp = createWaitPoint("my-step"); await wp.resolve("exec-1", () => ({ ok: true })); expect(resolveCalls[0]).toEqual({ executionId: "exec-1", key: "my-step" }); }); diff --git a/packages/sdk/src/configure/services/workflow/wait-point.ts b/packages/sdk/src/configure/services/workflow/wait-point.ts index feaf814d7..99c44c945 100644 --- a/packages/sdk/src/configure/services/workflow/wait-point.ts +++ b/packages/sdk/src/configure/services/workflow/wait-point.ts @@ -1,5 +1,5 @@ import { brandValue } from "#/utils/brand"; -import type { TailorWorkflowAPI } from "#/runtime/workflow"; +import type { PlatformWorkflowAPI } from "#/runtime/workflow"; import type { JsonCompatible, TypeLevelError } from "#/types/helpers"; /** @@ -37,7 +37,7 @@ interface WaitPointWithSetter { } function getPlatformWorkflow() { - const platform = globalThis as { tailor?: { workflow?: TailorWorkflowAPI } }; + const platform = globalThis as { tailor?: { workflow?: PlatformWorkflowAPI } }; const workflow = platform.tailor?.workflow; if (!workflow) { throw new Error( @@ -79,7 +79,7 @@ function createWaitPointInstance(initialKey: string): WaitPointWithSetter { } /** - * The type produced by `define()` / `defineWaitPoint(key)`. + * The type produced by `define()` / `createWaitPoint(key)`. * Resolves to `WaitPointInstance` when both types are JsonValue-compatible, * or to a type-level error that surfaces at the call site. */ @@ -100,7 +100,7 @@ type WaitPointDef = [null] extends [Payload] : TypeLevelError<"Payload must be JsonValue-compatible (plain objects/arrays; no class instances or functions)">; /** - * The `define` function passed to the `defineWaitPoints` builder callback. + * The `define` function passed to the `createWaitPoints` builder callback. * Returns an actual WaitPointInstance (not a phantom marker) so that the * builder's return type can flow through as-is, preserving JSDoc comments * on each property for IDE autocompletion. @@ -112,7 +112,7 @@ type WaitPointDef = [null] extends [Payload] type DefineFn = () => WaitPointDef; /** - * Define a single typed wait point with an explicit key. + * Create a single typed wait point with an explicit key. * * `Payload` and `Result` must be JsonValue-compatible. * Functions and objects with a `toJSON` method are rejected at the type level; @@ -120,19 +120,19 @@ type DefineFn = () => WaitPointDef("approval"); + * export const approval = createWaitPoint<{ message: string }, { approved: boolean }>("approval"); * * await approval.wait({ message: "Please approve" }); */ /* @__NO_SIDE_EFFECTS__ */ -export function defineWaitPoint( +export function createWaitPoint( key: string, ): WaitPointDef { return createWaitPointInstance(key).instance as unknown as WaitPointDef; } /** - * Define a group of typed wait points for human-in-the-loop workflows. + * Create a group of typed wait points for human-in-the-loop workflows. * Property names become the wait point keys. * * The return type is the same as the builder's return type, so JSDoc on each @@ -144,7 +144,7 @@ export function defineWaitPoint( * @param builder - Callback that receives a `define` factory and returns an object of wait points * @returns The same object returned by the builder (with correct keys set on each instance) * @example - * export const waitPoints = defineWaitPoints(define => ({ + * export const waitPoints = createWaitPoints(define => ({ * // Preceding JSDoc on this property is shown in IDE autocompletion * approval: define<{ message: string }, { approved: boolean }>(), * })); @@ -156,7 +156,7 @@ export function defineWaitPoint( */ /* @__NO_SIDE_EFFECTS__ */ // oxlint-disable-next-line no-explicit-any -export function defineWaitPoints>>( +export function createWaitPoints>>( builder: (define: DefineFn) => T, ): T { const setters = new Map void>(); diff --git a/packages/sdk/src/configure/services/workflow/workflow.ts b/packages/sdk/src/configure/services/workflow/workflow.ts index 4453f8e58..8165a452c 100644 --- a/packages/sdk/src/configure/services/workflow/workflow.ts +++ b/packages/sdk/src/configure/services/workflow/workflow.ts @@ -1,9 +1,8 @@ /* oxlint-disable typescript/no-explicit-any */ import { brandValue } from "#/utils/brand"; -import { dispatchTriggerWorkflow, registerWorkflow } from "./registry"; +import { dispatchStartWorkflow } from "./registry"; import type { MachineUserName } from "#/configure/types/machine-user"; import type { ConcurrencyPolicy, RetryPolicy } from "#/types/workflow.generated"; -import type { AuthInvoker } from "../auth"; import type { WorkflowJob } from "./job"; export type { ConcurrencyPolicy, RetryPolicy }; @@ -22,10 +21,12 @@ export interface Workflow = WorkflowJob[0], - options?: { authInvoker: AuthInvoker | MachineUserName }, - ) => Promise; + start: [Parameters[0]] extends [undefined] + ? (args?: undefined, options?: { invoker: MachineUserName }) => Promise + : ( + args: Parameters[0], + options?: { invoker: MachineUserName }, + ) => Promise; } interface WorkflowDefinition> { @@ -36,8 +37,8 @@ interface WorkflowDefinition> { } /** - * Create a workflow definition that can be triggered via the Tailor SDK. - * In production, bundler transforms .trigger() calls to tailor.workflow.triggerWorkflow(). + * Create a workflow definition that can be started via the Tailor SDK. + * In production, the bundler rewrites `.start()` calls into direct platform workflow calls. * * The workflow MUST be the default export of the file. * All jobs referenced by the workflow MUST be named exports. @@ -48,8 +49,8 @@ interface WorkflowDefinition> { * export const fetchData = createWorkflowJob({ name: "fetch-data", body: async (input: { id: string }) => ({ id: input.id }) }); * export const processData = createWorkflowJob({ * name: "process-data", - * body: async (input: { id: string }) => { - * const data = await fetchData.trigger({ id: input.id }); + * body: (input: { id: string }) => { + * const data = fetchData.start({ id: input.id }); * return { data }; * }, * }); @@ -63,33 +64,28 @@ interface WorkflowDefinition> { export function createWorkflow>( config: WorkflowDefinition, ): Workflow { - // Test-only registry/trigger shim; the platform bundle sets the flag so it is DCE'd. - if (!process.env.TAILOR_PLATFORM_BUNDLE) { - registerWorkflow(config.name, config.mainJob.name); - } - return brandValue( { ...config, - trigger: process.env.TAILOR_PLATFORM_BUNDLE + start: process.env.__TAILOR_PLATFORM_BUNDLE ? async () => { throw new Error( - "workflow.trigger() is rewritten at build time and unavailable in the bundle", + "workflow.start() is rewritten at build time and unavailable in the bundle", ); } : // Preserve arity: use `arguments.length` (regular function, not arrow) so - // `.trigger(args, undefined)` is treated as "options passed" — matching + // `.start(args, undefined)` is treated as "options passed" — matching // the bundler rewrite, which forwards the literal `undefined` from the // AST as a third argument. Without this, local execution and bundled // workflows would hand mocks different call shapes. - async function trigger( - args: Parameters[0], - options?: { authInvoker: AuthInvoker | MachineUserName }, + async function start( + args: Parameters[0], + options?: { invoker: MachineUserName }, ) { // oxlint-disable-next-line prefer-rest-params return arguments.length >= 2 - ? await dispatchTriggerWorkflow(config.name, args, options) - : await dispatchTriggerWorkflow(config.name, args); + ? await dispatchStartWorkflow(config.name, args, options) + : await dispatchStartWorkflow(config.name, args); }, } as Workflow, "workflow", diff --git a/packages/sdk/src/configure/types/aigateway-name.ts b/packages/sdk/src/configure/types/aigateway-name.ts index ce1797b86..da0644508 100644 --- a/packages/sdk/src/configure/types/aigateway-name.ts +++ b/packages/sdk/src/configure/types/aigateway-name.ts @@ -6,7 +6,7 @@ export interface AIGatewayNameRegistry {} /** * AI Gateway name. * - * When `tailor.d.ts` is generated (via `tailor-sdk deploy`/`generate`), this is narrowed + * When `tailor.d.ts` is generated (via `tailor deploy`/`generate`), this is narrowed * to the union of AI Gateway names defined via `defineAIGateway()`. Falls back to * `string` before the first generate run. */ diff --git a/packages/sdk/src/configure/types/connection-name.ts b/packages/sdk/src/configure/types/connection-name.ts index 710342fb6..5eb670a54 100644 --- a/packages/sdk/src/configure/types/connection-name.ts +++ b/packages/sdk/src/configure/types/connection-name.ts @@ -6,7 +6,7 @@ export interface ConnectionNameRegistry {} /** * Auth connection name. * - * When `tailor.d.ts` is generated (via `tailor-sdk deploy`/`generate`), this is narrowed + * When `tailor.d.ts` is generated (via `tailor deploy`/`generate`), this is narrowed * to the union of connection names defined in `defineAuth()`'s `connections`. Falls back * to `string` before the first generate run. */ diff --git a/packages/sdk/src/configure/types/field.types.ts b/packages/sdk/src/configure/types/field.types.ts index 7f9dcff4e..c93169256 100644 --- a/packages/sdk/src/configure/types/field.types.ts +++ b/packages/sdk/src/configure/types/field.types.ts @@ -74,68 +74,15 @@ export type ArrayFieldOutput = [O] extends [ ? T[] : T; -import type { TailorUser } from "#/runtime/types"; -import type { output, InferFieldsOutput } from "#/types/helpers"; -import type { NonEmptyObject } from "type-fest"; - -/** - * Validation function type - */ -export type ValidateFn = (args: { value: O; data: D; user: TailorUser }) => boolean; - -/** - * Validation configuration with custom error message - */ -export type ValidateConfig = [ValidateFn, string]; - -/** - * Field-level validation function - */ -type FieldValidateFn = ValidateFn; - /** - * Field-level validation configuration + * Field validation function. Return an error message string to fail, or void/undefined to pass. */ -type FieldValidateConfig = ValidateConfig; +export type ValidateFn = (args: { value: O }) => string | void; /** - * Input type for field validation - can be either a function or a tuple of [function, errorMessage] + * Input type for field validation */ -export type FieldValidateInput = FieldValidateFn | FieldValidateConfig; - -/** - * Base validators type for field collections - * @template F - Record of fields - * @template ExcludeKeys - Keys to exclude from validation (default: "id" for TailorDB) - */ -type ValidatorsBase< - // Structural constraint only - // oxlint-disable-next-line no-explicit-any - F extends Record, - ExcludeKeys extends string = "id", -> = NonEmptyObject<{ - [K in Exclude as F[K]["_defined"] extends { - validate: unknown; - } - ? never - : K]?: - | ValidateFn, InferFieldsOutput> - | ValidateConfig, InferFieldsOutput> - | ( - | ValidateFn, InferFieldsOutput> - | ValidateConfig, InferFieldsOutput> - )[]; -}>; - -/** - * Validators type (by default excludes "id" field for TailorDB compatibility) - * Can be used with both TailorField and TailorDBField - */ -export type Validators< - // Structural constraint only - // oxlint-disable-next-line no-explicit-any - F extends Record, -> = ValidatorsBase; +export type FieldValidateInput = ValidateFn; /** * Minimal structural interface for TailorField. diff --git a/packages/sdk/src/configure/types/idp-name.ts b/packages/sdk/src/configure/types/idp-name.ts index a6aa946e8..db0e3908b 100644 --- a/packages/sdk/src/configure/types/idp-name.ts +++ b/packages/sdk/src/configure/types/idp-name.ts @@ -6,7 +6,7 @@ export interface IdpNameRegistry {} /** * IdP namespace name. * - * When `tailor.d.ts` is generated (via `tailor-sdk deploy`/`generate`), this is narrowed + * When `tailor.d.ts` is generated (via `tailor deploy`/`generate`), this is narrowed * to the union of defined IdP names. When no IdPs are registered yet, falls back to * `string` to avoid blocking editing before the first generate run. */ diff --git a/packages/sdk/src/configure/types/machine-user.ts b/packages/sdk/src/configure/types/machine-user.ts index 924afecc2..ad71dfef0 100644 --- a/packages/sdk/src/configure/types/machine-user.ts +++ b/packages/sdk/src/configure/types/machine-user.ts @@ -1,15 +1 @@ -// Interface for module augmentation -// Users can extend via: declare module "@tailor-platform/sdk" { interface MachineUserNameRegistry { ... } } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface MachineUserNameRegistry {} - -/** - * Machine user name. - * - * When `tailor.d.ts` is generated (via `tailor-sdk deploy`/`generate`), this is narrowed - * to the union of defined machine user names. When no machine users are registered yet, - * falls back to `string` to avoid blocking editing before the first generate run. - */ -export type MachineUserName = keyof MachineUserNameRegistry extends never - ? string - : keyof MachineUserNameRegistry & string; +export type { MachineUserName, MachineUserNameRegistry } from "#/configure/services/auth/types"; diff --git a/packages/sdk/src/configure/types/type-typename.test.ts b/packages/sdk/src/configure/types/type-typename.test.ts index 65a2dbd5d..c61da6699 100644 --- a/packages/sdk/src/configure/types/type-typename.test.ts +++ b/packages/sdk/src/configure/types/type-typename.test.ts @@ -24,7 +24,7 @@ describe("typeName method type safety", () => { TypeEquals >; - const validated = t.string().validate(() => true); + const validated = t.string().validate(() => undefined); type _ValidateDuplicate = Expect< TypeEquals >; diff --git a/packages/sdk/src/configure/types/type.test.ts b/packages/sdk/src/configure/types/type.test.ts index 96abb34a5..c493ad043 100644 --- a/packages/sdk/src/configure/types/type.test.ts +++ b/packages/sdk/src/configure/types/type.test.ts @@ -1,11 +1,11 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. import { describe, expect, test, expectTypeOf, vi } from "vitest"; import { t } from "./type"; -import type { TailorUser } from "#/runtime/types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { output } from "#/types/helpers"; import type { AllowedValues } from "./field"; -const user: TailorUser = { +const invoker: TailorPrincipal = { id: "test", type: "user", workspaceId: "workspace-test", @@ -515,10 +515,10 @@ describe("t.object tests", () => { describe("TailorField runtime validation tests", () => { describe("validates primitive types", () => { test("validates string type", () => { - const ok = t.string().parse({ value: "valid string", data, user }); + const ok = t.string().parse({ value: "valid string", data, invoker }); expect(expectParsed(ok)).toBe("valid string"); - const bad = t.string().parse({ value: 123, data, user }); + const bad = t.string().parse({ value: 123, data, invoker }); expect(bad.issues).toBeDefined(); expect(bad.issues?.[0]?.message).toEqual("Expected a string: received 123"); expect(bad.issues?.[0]?.path).toBeUndefined(); @@ -528,13 +528,13 @@ describe("TailorField runtime validation tests", () => { { value: "invalid string", message: "Expected an integer: received invalid string" }, { value: 1.5, message: "Expected an integer: received 1.5" }, ])("validates integer type - rejects $value", ({ value, message }) => { - const result = t.int().parse({ value, data, user }); + const result = t.int().parse({ value, data, invoker }); expect(result.issues).toBeDefined(); expect(result.issues?.[0]?.message).toEqual(message); }); test("validates integer type - accepts a valid integer", () => { - const result = t.int().parse({ value: 123, data, user }); + const result = t.int().parse({ value: 123, data, invoker }); expect(expectParsed(result)).toBe(123); }); @@ -542,21 +542,21 @@ describe("TailorField runtime validation tests", () => { { value: Number.NaN, message: "Expected a number: received NaN" }, { value: "invalid string", message: "Expected a number: received invalid string" }, ])("validates float type - rejects $value", ({ value, message }) => { - const result = t.float().parse({ value, data, user }); + const result = t.float().parse({ value, data, invoker }); expect(result.issues).toBeDefined(); expect(result.issues?.[0]?.message).toEqual(message); }); test("validates float type - accepts a valid float", () => { - const result = t.float().parse({ value: 1.5, data, user }); + const result = t.float().parse({ value: 1.5, data, invoker }); expect(expectParsed(result)).toBe(1.5); }); test("validates boolean type", () => { - const ok = t.bool().parse({ value: true, data, user }); + const ok = t.bool().parse({ value: true, data, invoker }); expect(expectParsed(ok)).toBe(true); - const bad = t.bool().parse({ value: "true", data, user }); + const bad = t.bool().parse({ value: "true", data, invoker }); expect(bad.issues).toBeDefined(); expect(bad.issues?.[0]?.message).toEqual("Expected a boolean: received true"); }); @@ -593,21 +593,31 @@ describe("TailorField runtime validation tests", () => { invalidMessage: 'Expected to match "HH:mm" format: received 10:11:12', }, ])("validates $name format", ({ field, validValue, invalidValue, invalidMessage }) => { - const ok = field.parse({ value: validValue, data, user }); + const ok = field.parse({ value: validValue, data, invoker }); expect(expectParsed(ok)).toBe(validValue); - const bad = field.parse({ value: invalidValue, data, user }); + const bad = field.parse({ value: invalidValue, data, invoker }); expect(bad.issues).toBeDefined(); expect(bad.issues?.[0]?.message).toEqual(invalidMessage); }); - test("rejects a non-dot datetime fractional-second separator", () => { - const value = "2025-12-21T10:11:12x123Z"; - const result = t.datetime().parse({ value, data, user }); - - expect(result.issues?.[0]?.message).toEqual( - `Expected to match ISO format: received ${value}`, - ); + test.each([ + { + name: "out-of-range time", + field: t.time(), + value: "99:99", + message: 'Expected to match "HH:mm" format: received 99:99', + }, + { + name: "datetime with a non-dot fractional separator", + field: t.datetime(), + value: "2025-12-21T10:11:12x123Z", + message: "Expected to match ISO format: received 2025-12-21T10:11:12x123Z", + }, + ])("rejects $name", ({ field, value, message }) => { + const result = field.parse({ value, data, invoker }); + expect(result.issues).toBeDefined(); + expect(result.issues?.[0]?.message).toEqual(message); }); }); @@ -615,10 +625,10 @@ describe("TailorField runtime validation tests", () => { test("validates enum values", () => { const status = t.enum(["active", "inactive"]); - const ok = status.parse({ value: "active", data, user }); + const ok = status.parse({ value: "active", data, invoker }); expect(expectParsed(ok)).toBe("active"); - const bad = status.parse({ value: "pending", data, user }); + const bad = status.parse({ value: "pending", data, invoker }); expect(bad.issues).toBeDefined(); expect(bad.issues?.[0]?.message).toEqual( "Must be one of [active, inactive]: received pending", @@ -635,11 +645,11 @@ describe("TailorField runtime validation tests", () => { const ok = schema.parse({ value: { name: "name", age: null, gender: "male" }, data, - user, + invoker, }); expect(expectParsed(ok)).toEqual({ name: "name", age: null, gender: "male" }); - const bad = schema.parse({ value: { age: 1, gender: "invalid" }, data, user }); + const bad = schema.parse({ value: { age: 1, gender: "invalid" }, data, invoker }); expect(bad.issues).toBeDefined(); expect(bad.issues).toEqual([ { message: "Required field is missing", path: ["name"] }, @@ -648,7 +658,7 @@ describe("TailorField runtime validation tests", () => { const notAnObjectSchema = t.object({ value: t.string({ optional: true }) }); const now = new Date(); - const notAnObject = notAnObjectSchema.parse({ value: now, data, user }); + const notAnObject = notAnObjectSchema.parse({ value: now, data, invoker }); expect(notAnObject.issues).toBeDefined(); expect(notAnObject.issues?.[0]?.message).toEqual( `Expected an object: received ${String(now)}`, @@ -656,14 +666,14 @@ describe("TailorField runtime validation tests", () => { }); test("runs parent custom validation when nested custom validation fails", () => { - const parentValidate = vi.fn(() => false); + const parentValidate = vi.fn((): string | void => "Parent validation failed"); const schema = t .object({ - name: t.string().validate([() => false, "Child validation failed"]), + name: t.string().validate(() => "Child validation failed"), }) - .validate([parentValidate, "Parent validation failed"]); + .validate(parentValidate); - const result = schema.parse({ value: { name: "valid" }, data, user }); + const result = schema.parse({ value: { name: "valid" }, data, invoker }); expect(result.issues).toEqual([ { message: "Child validation failed", path: ["name"] }, @@ -673,9 +683,8 @@ describe("TailorField runtime validation tests", () => { }); test("finishes nested base validation before running custom validation", () => { - const validate = vi.fn(({ data }: { data: unknown }) => { - const record = data as { unsafe: string }; - return record.unsafe.toUpperCase() === "VALID"; + const validate = vi.fn(({ value }: { value: string }): string | void => { + if (value !== "valid") return "Not valid"; }); const schema = t.object({ safe: t.string().validate(validate), @@ -683,7 +692,7 @@ describe("TailorField runtime validation tests", () => { }); const value = { safe: "valid", unsafe: 42 }; - const result = schema.parse({ value, data: value, user }); + const result = schema.parse({ value, data: value, invoker }); expect(result.issues).toEqual([ { message: "Expected a string: received 42", path: ["unsafe"] }, @@ -694,14 +703,14 @@ describe("TailorField runtime validation tests", () => { test("validates array fields and element paths", () => { const schema = t.int({ array: true }); - const ok = schema.parse({ value: [1, 2, 3], data, user }); + const ok = schema.parse({ value: [1, 2, 3], data, invoker }); expect(expectParsed(ok)).toEqual([1, 2, 3]); - const notAnArray = schema.parse({ value: "invalid", data, user }); + const notAnArray = schema.parse({ value: "invalid", data, invoker }); expect(notAnArray.issues).toBeDefined(); expect(notAnArray.issues?.[0]?.message).toEqual("Expected an array"); - const badElement = schema.parse({ value: [1, "x"], data, user }); + const badElement = schema.parse({ value: [1, "x"], data, invoker }); expect(badElement.issues).toBeDefined(); expect(badElement.issues?.[0]).toEqual({ path: ["[1]"], @@ -710,50 +719,56 @@ describe("TailorField runtime validation tests", () => { }); test("runs custom validation once against the complete array", () => { - const validate = vi.fn(({ value }: { value: string[] }) => value.length >= 2); - const schema = t.string({ array: true }).validate([validate, "Expected at least two values"]); + const validate = vi.fn(({ value }: { value: string[] }): string | void => { + if (value.length < 2) return "Expected at least two values"; + }); + const schema = t.string({ array: true }).validate(validate); - const validResult = schema.parse({ value: ["a", "b"], data, user }); - const invalidResult = schema.parse({ value: ["a"], data, user }); + const validResult = schema.parse({ value: ["a", "b"], data, invoker }); + const invalidResult = schema.parse({ value: ["a"], data, invoker }); expect(expectParsed(validResult)).toEqual(["a", "b"]); expect(invalidResult.issues).toEqual([{ message: "Expected at least two values" }]); expect(validate).toHaveBeenCalledTimes(2); - expect(validate).toHaveBeenNthCalledWith(1, { value: ["a", "b"], data, user }); - expect(validate).toHaveBeenNthCalledWith(2, { value: ["a"], data, user }); + expect(validate).toHaveBeenNthCalledWith(1, { value: ["a", "b"] }); + expect(validate).toHaveBeenNthCalledWith(2, { value: ["a"] }); }); test("skips custom validation when scalar base validation fails", () => { - const validate = vi.fn(({ value }: { value: string }) => value.toUpperCase().length > 0); + const validate = vi.fn(({ value }: { value: string }): string | void => { + if (value.toUpperCase().length === 0) return "Value is empty"; + }); const schema = t.string().validate(validate); - const result = schema.parse({ value: 42, data, user }); + const result = schema.parse({ value: 42, data, invoker }); expect(result.issues).toEqual([{ message: "Expected a string: received 42" }]); expect(validate).not.toHaveBeenCalled(); }); test("skips array custom validation when element base validation fails", () => { - const validate = vi.fn(({ value }: { value: string[] }) => value.length > 0); + const validate = vi.fn(({ value }: { value: string[] }): string | void => { + if (value.length === 0) return "Array is empty"; + }); const schema = t.string({ array: true }).validate(validate); - const result = schema.parse({ value: ["valid", 42], data, user }); + const result = schema.parse({ value: ["valid", 42], data, invoker }); expect(result.issues).toEqual([{ message: "Expected a string: received 42", path: ["[1]"] }]); expect(validate).not.toHaveBeenCalled(); }); test("treats null/undefined as missing when required, and allowed when optional", () => { - const required = t.string().parse({ value: null, data, user }); + const required = t.string().parse({ value: null, data, invoker }); expect(required.issues).toBeDefined(); expect(required.issues?.[0]?.message).toEqual("Required field is missing"); - const optionalScalar = t.string({ optional: true }).parse({ value: null, data, user }); + const optionalScalar = t.string({ optional: true }).parse({ value: null, data, invoker }); expect(expectParsed(optionalScalar)).toBeNull(); const optionalArray = t .int({ optional: true, array: true }) - .parse({ value: null, data, user }); + .parse({ value: null, data, invoker }); expect(expectParsed(optionalArray)).toBeNull(); }); }); @@ -771,14 +786,14 @@ describe("TailorField runtime validation tests", () => { "2.41E-3", "-1.5e10", ])("accepts valid decimal string %s", (value) => { - const result = t.decimal().parse({ value, data, user }); + const result = t.decimal().parse({ value, data, invoker }); expect(expectParsed(result)).toBe(value); }); test.each(["abc", "", "1_000_000", "0b1.1p-5", "1e", "e5", ".", 123])( "rejects invalid decimal value %s", (value) => { - const result = t.decimal().parse({ value, data, user }); + const result = t.decimal().parse({ value, data, invoker }); expect(result.issues).toBeDefined(); }, ); @@ -806,7 +821,9 @@ describe("TailorField clone-on-write / no aliasing", () => { test("validate() returns a clone and never mutates the original", () => { const original = t.string(); - const updated = original.validate((args) => args.value.length > 0); + const updated = original.validate((args) => + args.value.length <= 0 ? "Must not be empty" : undefined, + ); expect(original.metadata.validate).toBeUndefined(); expect(updated.metadata.validate).toHaveLength(1); @@ -868,7 +885,9 @@ describe("TailorField clone-on-write / no aliasing", () => { expect(enumClone.metadata.allowedValues).not.toBe(enumField.metadata.allowedValues); expect(enumClone.metadata.allowedValues?.[0]).not.toBe(enumField.metadata.allowedValues?.[0]); - const validated = t.string().validate((args) => args.value.length > 0); + const validated = t + .string() + .validate((args) => (args.value.length <= 0 ? "Must not be empty" : undefined)); const validatedClone = validated.description("name"); expect(validatedClone.metadata.validate).not.toBe(validated.metadata.validate); }); @@ -877,10 +896,10 @@ describe("TailorField clone-on-write / no aliasing", () => { const status = t.enum(["active", "inactive"]); const cloned = status.description("status field"); - const ok = cloned.parse({ value: "active", data, user }); + const ok = cloned.parse({ value: "active", data, invoker }); expect(ok.issues).toBeUndefined(); - const ng = cloned.parse({ value: "pending", data, user }); + const ng = cloned.parse({ value: "pending", data, invoker }); expect(ng.issues).toBeDefined(); expect(ng.issues?.[0]?.message).toEqual("Must be one of [active, inactive]: received pending"); }); @@ -889,16 +908,18 @@ describe("TailorField clone-on-write / no aliasing", () => { const calls: unknown[] = []; const field = t.string().validate((args) => { calls.push(args.value); - return args.value.length > 0; + return args.value.length <= 0 ? "Must not be empty" : undefined; }); - const result = field.parse({ value: "x", data, user }); + const result = field.parse({ value: "x", data, invoker }); expect(result.issues).toBeUndefined(); expect(calls).toEqual(["x"]); }); test("validators survive a clone triggered by a later builder, leaving the original intact", () => { - const validated = t.string().validate((args) => args.value.length > 0); + const validated = t + .string() + .validate((args) => (args.value.length <= 0 ? "Must not be empty" : undefined)); // description() clones the field; the validators must carry over to the clone // and keep working, while the original stays unchanged. const described = validated.description("name"); @@ -907,7 +928,7 @@ describe("TailorField clone-on-write / no aliasing", () => { expect(described.metadata.description).toBe("name"); expect(validated.metadata.description).toBeUndefined(); - const failed = described.parse({ value: "", data, user }); + const failed = described.parse({ value: "", data, invoker }); expect(failed.issues).toBeDefined(); }); }); diff --git a/packages/sdk/src/configure/types/type.ts b/packages/sdk/src/configure/types/type.ts index 161a607c5..508a71bf4 100644 --- a/packages/sdk/src/configure/types/type.ts +++ b/packages/sdk/src/configure/types/type.ts @@ -14,7 +14,7 @@ import type { TailorField as TailorFieldBase, FieldValidateInput, } from "#/configure/types/field.types"; -import type { InferFieldsOutput, TypeLevelError } from "#/types/helpers"; +import type { InferFieldsOutput, Prettify, TypeLevelError, output } from "#/types/helpers"; import type { StandardSchemaV1 } from "@standard-schema/spec"; // Erased fields stay assignable across builder method-state changes. @@ -106,7 +106,7 @@ export interface TailorField< /** * Parse and validate a value against this field's validation rules * Returns StandardSchema Result type with success or failure - * @param args - Value, context data, and user + * @param args - Value, context data, and invoker * @returns Validation result */ parse(args: FieldParseArgs): StandardSchemaV1.Result; @@ -265,7 +265,7 @@ function createTailorField< return parseInternal({ value: args.value, data: args.data, - user: args.user, + invoker: args.invoker, pathArray: [], }); }, @@ -402,6 +402,19 @@ function _enum( return createTailorField<"enum", Opt, AllowedValuesOutput>("enum", options, undefined, values); } +type DefaultFieldKeys = { + [K in keyof F]: F[K] extends { _defined: { default: true } } ? K : never; +}[keyof F]; + +type InferFieldsOutputWithDefaults< + // oxlint-disable-next-line no-explicit-any + F extends Record, +> = Prettify< + Omit, DefaultFieldKeys & string> & { + [K in DefaultFieldKeys & keyof F]?: output; + } +>; + /** * Create a nested object field for resolver input/output. * @param fields - Record of field definitions @@ -420,7 +433,7 @@ function object, const Opt extend ) { const objectField = createTailorField("nested", options, fields) as TailorField< { type: "nested"; array: Opt extends { array: true } ? true : false }, - FieldOutput, Opt> + FieldOutput, Opt> >; return objectField; } diff --git a/packages/sdk/src/configure/user.ts b/packages/sdk/src/configure/user.ts deleted file mode 100644 index 76dd0ffa3..000000000 --- a/packages/sdk/src/configure/user.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { TailorUser } from "#/runtime/types"; - -/** Represents an unauthenticated user in the Tailor platform. */ -export const unauthenticatedTailorUser: TailorUser = { - id: "00000000-0000-0000-0000-000000000000", - type: "", - workspaceId: "00000000-0000-0000-0000-000000000000", - attributes: null, - attributeList: [], -}; diff --git a/packages/sdk/src/parser/app-config/schema.test.ts b/packages/sdk/src/parser/app-config/schema.test.ts index 807261588..d11b94e41 100644 --- a/packages/sdk/src/parser/app-config/schema.test.ts +++ b/packages/sdk/src/parser/app-config/schema.test.ts @@ -47,12 +47,23 @@ describe("AppConfigSchema", () => { expect(result.success).toBe(true); }); - test("ignores unknown top-level fields without erroring", () => { + test("rejects unknown top-level fields", () => { const result = AppConfigSchema.safeParse({ name: "my-app", futureField: "ok", }); - expect(result.success).toBe(true); + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected AppConfigSchema parsing to fail"); + } + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unrecognized_keys", + keys: ["futureField"], + }), + ]), + ); }); test("rejects when env value type is unsupported", () => { diff --git a/packages/sdk/src/parser/app-config/schema.ts b/packages/sdk/src/parser/app-config/schema.ts index 193a90e59..1299decef 100644 --- a/packages/sdk/src/parser/app-config/schema.ts +++ b/packages/sdk/src/parser/app-config/schema.ts @@ -22,7 +22,7 @@ const logLevelSchema = z * label-compatible prefix is added at the metadata boundary, so user-facing * configs only need to carry a UUID. */ -export const AppConfigSchema = z.object({ +export const AppConfigSchema = z.strictObject({ id: z.uuid({ message: "'id' must be a UUID." }).optional(), name: z.string().min(1, { message: "'name' must be a non-empty string." }), env: z.record(z.string(), envValueSchema).optional(), diff --git a/packages/sdk/src/parser/generator-config/schema.ts b/packages/sdk/src/parser/generator-config/schema.ts deleted file mode 100644 index 936d0f346..000000000 --- a/packages/sdk/src/parser/generator-config/schema.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { z } from "zod"; - -// Dependency kind enum for generators -const DependencyKindSchema = z.enum(["tailordb", "resolver", "executor"]); -export type DependencyKind = z.infer; - -// Literal-based schemas for each generator -const KyselyTypeConfigSchema = z.tuple([ - z.literal("@tailor-platform/kysely-type"), - z.object({ distPath: z.string() }), -]); - -const SeedConfigSchema = z.tuple([ - z.literal("@tailor-platform/seed"), - z.object({ - distPath: z.string(), - machineUserName: z.string().optional(), - disableIdpUserSync: z - .object({ - userToIdp: z.boolean().optional(), - idpToUser: z.boolean().optional(), - }) - .optional(), - }), -]); - -const EnumConstantsConfigSchema = z.tuple([ - z.literal("@tailor-platform/enum-constants"), - z.object({ distPath: z.string() }), -]); - -const FileUtilsConfigSchema = z.tuple([ - z.literal("@tailor-platform/file-utils"), - z.object({ distPath: z.string() }), -]); - -// Custom generator schema with dependencies -export const CodeGeneratorSchema = z.object({ - id: z.string(), - description: z.string(), - dependencies: z.array(DependencyKindSchema), - processType: z.function().optional(), - processResolver: z.function().optional(), - processExecutor: z.function().optional(), - processTailorDBNamespace: z.function().optional(), - processResolverNamespace: z.function().optional(), - aggregate: z.function({ output: z.any() }), -}); - -// Base schema for generator config (before transformation to actual Generator instances) -export const BaseGeneratorConfigSchema = z.union([ - KyselyTypeConfigSchema, - SeedConfigSchema, - EnumConstantsConfigSchema, - FileUtilsConfigSchema, - CodeGeneratorSchema, -]); diff --git a/packages/sdk/src/parser/plugin-config/schema.ts b/packages/sdk/src/parser/plugin-config/schema.ts index bf1f19de7..cdae7f1b2 100644 --- a/packages/sdk/src/parser/plugin-config/schema.ts +++ b/packages/sdk/src/parser/plugin-config/schema.ts @@ -5,7 +5,7 @@ import type { Plugin } from "#/plugin/types"; // Custom plugin schema (object form) // Using passthrough() to preserve additional properties on Plugin instances export const PluginConfigSchema = z - .object({ + .looseObject({ id: z.string(), description: z.string(), importPath: z.string().optional(), @@ -19,7 +19,6 @@ export const PluginConfigSchema = z onResolverReady: functionSchema.optional(), onExecutorReady: functionSchema.optional(), }) - .passthrough() .refine( (p) => { // importPath is required when plugin has definition-time hooks diff --git a/packages/sdk/src/parser/schema-strict.test.ts b/packages/sdk/src/parser/schema-strict.test.ts new file mode 100644 index 000000000..c4693bb6d --- /dev/null +++ b/packages/sdk/src/parser/schema-strict.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "vitest"; +import { AppConfigSchema } from "./app-config/schema"; +import { PluginConfigSchema } from "./plugin-config/schema"; +import { AIGatewaySchema } from "./service/aigateway/schema"; +import { AuthConnectionConfigSchema } from "./service/auth-connection/schema"; +import { AuthConfigSchema, SCIMAttributeSchema } from "./service/auth/schema"; +import { ExecutorSchema } from "./service/executor/schema"; +import { TailorFieldSchema } from "./service/field/schema"; +import { IdPSchema } from "./service/idp/schema"; +import { ResolverSchema } from "./service/resolver/schema"; +import { SecretsSchema } from "./service/secrets/schema"; +import { StaticWebsiteSchema } from "./service/staticwebsite/schema"; +import { TailorDBServiceConfigSchema, TailorDBTypeSchema } from "./service/tailordb/schema"; +import { WorkflowSchema } from "./service/workflow/schema"; +import type { ZodType } from "zod"; + +type StrictSchemaCase = { + readonly name: string; + readonly schema: ZodType; + readonly value: Record; +}; + +function hasUnrecognizedKeyIssue(issues: readonly unknown[]): boolean { + return issues.some((issue) => { + if (typeof issue !== "object" || issue === null) return false; + if ("code" in issue && issue.code === "unrecognized_keys") return true; + if (!("errors" in issue) || !Array.isArray(issue.errors)) return false; + return issue.errors.some( + (variantIssues) => Array.isArray(variantIssues) && hasUnrecognizedKeyIssue(variantIssues), + ); + }); +} + +const strictSchemaCases: StrictSchemaCase[] = [ + { + name: "app config", + schema: AppConfigSchema, + value: { name: "my-app" }, + }, + { + name: "AI gateway", + schema: AIGatewaySchema, + value: { name: "my-gateway", authNamespace: "my-auth" }, + }, + { + name: "auth connection", + schema: AuthConnectionConfigSchema, + value: { + type: "oauth2", + providerUrl: "https://accounts.example.com", + issuerUrl: "https://accounts.example.com", + clientId: "client-id", + clientSecret: "client-secret", + }, + }, + { + name: "auth config", + schema: AuthConfigSchema, + value: { name: "my-auth" }, + }, + { + name: "SCIM attribute", + schema: SCIMAttributeSchema, + value: { type: "string", name: "userName" }, + }, + { + name: "executor", + schema: ExecutorSchema, + value: { + name: "my-executor", + trigger: { kind: "schedule", cron: "0 12 * * *" }, + operation: { kind: "function", body: () => {} }, + }, + }, + { + name: "IdP", + schema: IdPSchema, + value: { name: "my-idp", authorization: "loggedIn", clients: ["default-client"] }, + }, + { + name: "resolver", + schema: ResolverSchema, + value: { + operation: "query", + name: "getUser", + body: () => {}, + output: { type: "string", metadata: {}, fields: {} }, + }, + }, + { + name: "secrets", + schema: SecretsSchema, + value: { + vaults: { "my-vault": { secret: "value" } }, + options: { ignoreNullishValues: true }, + }, + }, + { + name: "static website", + schema: StaticWebsiteSchema, + value: { name: "my-site" }, + }, + { + name: "TailorDB service config", + schema: TailorDBServiceConfigSchema, + value: { files: ["tailordb/*.ts"] }, + }, + { + name: "TailorDB type", + schema: TailorDBTypeSchema, + value: { + name: "User", + fields: {}, + metadata: { + name: "User", + permissions: {}, + files: {}, + }, + }, + }, + { + name: "workflow", + schema: WorkflowSchema, + value: { + name: "my-workflow", + mainJob: { + name: "main", + trigger: () => {}, + body: () => {}, + }, + }, + }, +]; + +describe("parser schemas", () => { + test.each(strictSchemaCases)("rejects unknown keys for $name", ({ schema, value }) => { + const result = schema.safeParse({ ...value, unknownOption: true }); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected schema parsing to fail"); + } + expect(hasUnrecognizedKeyIssue(result.error.issues)).toBe(true); + }); + + test("preserves plugin instance properties", () => { + const result = PluginConfigSchema.safeParse({ + id: "plugin", + description: "Plugin", + customProperty: true, + }); + + expect(result.success).toBe(true); + if (!result.success) { + throw new Error("Expected plugin config parsing to succeed"); + } + expect(result.data).toHaveProperty("customProperty", true); + }); + + test("accepts field builder properties", () => { + const result = TailorFieldSchema.safeParse({ + type: "string", + metadata: { + validate: [() => true, () => "Invalid value"], + }, + fields: {}, + builderProperty: true, + }); + + expect(result.success).toBe(true); + if (!result.success) { + throw new Error("Expected field parsing to succeed"); + } + }); +}); diff --git a/packages/sdk/src/parser/service/aigateway/schema.ts b/packages/sdk/src/parser/service/aigateway/schema.ts index 14c5d74a7..a1a63005c 100644 --- a/packages/sdk/src/parser/service/aigateway/schema.ts +++ b/packages/sdk/src/parser/service/aigateway/schema.ts @@ -3,8 +3,9 @@ import { z } from "zod"; const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/; const AUTH_NAMESPACE_PATTERN = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/; +// strip unknown keys export const AIGatewaySchema = z - .object({ + .strictObject({ name: z .string() .regex(NAME_PATTERN, "Must be 3-30 lowercase alphanumeric characters or hyphens") @@ -20,4 +21,5 @@ export const AIGatewaySchema = z "Allowed CORS origins for browser-based clients. Each entry is `*`, `http(s)://*`, `http(s)://*.example.com`, or `http(s)://app.example.com`, optionally with `:port`. Empty list disables cross-origin access.", ), }) + .brand("AIGatewayConfig"); diff --git a/packages/sdk/src/parser/service/auth-connection/schema.ts b/packages/sdk/src/parser/service/auth-connection/schema.ts index c99092b48..30d662c39 100644 --- a/packages/sdk/src/parser/service/auth-connection/schema.ts +++ b/packages/sdk/src/parser/service/auth-connection/schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const AuthConnectionOAuth2ConfigSchema = z.object({ +export const AuthConnectionOAuth2ConfigSchema = z.strictObject({ providerUrl: z.string().describe("OAuth2 provider URL"), issuerUrl: z.string().describe("OAuth2 issuer URL"), clientId: z.string().describe("OAuth2 client ID"), @@ -9,8 +9,6 @@ export const AuthConnectionOAuth2ConfigSchema = z.object({ tokenUrl: z.string().optional().describe("OAuth2 token endpoint override"), }); -export const AuthConnectionConfigSchema = z - .object({ - type: z.literal("oauth2").describe("Connection type"), - }) - .and(AuthConnectionOAuth2ConfigSchema); +export const AuthConnectionConfigSchema = AuthConnectionOAuth2ConfigSchema.extend({ + type: z.literal("oauth2").describe("Connection type"), +}); diff --git a/packages/sdk/src/parser/service/auth/index.test.ts b/packages/sdk/src/parser/service/auth/index.test.ts index adf034170..a4f32fd37 100644 --- a/packages/sdk/src/parser/service/auth/index.test.ts +++ b/packages/sdk/src/parser/service/auth/index.test.ts @@ -1,13 +1,15 @@ import { describe, expectTypeOf, expect, test } from "vitest"; import { db } from "#/configure/services/tailordb/schema"; import { t } from "#/configure/types/type"; +import { brandValue } from "#/utils/brand"; import { AuthConfigSchema, OAuth2ClientSchema } from "./schema"; import type { AuthServiceInput } from "#/configure/services/auth/types"; +import type { TailorDBInstance } from "#/configure/services/tailordb/types"; import type { OptionalKeysOf } from "type-fest"; import type { z } from "zod"; // Define userType for type inference -const userType = db.type("User", { +const userType = db.table("User", { email: db.string().unique(), role: db.string(), isActive: db.bool(), @@ -15,7 +17,7 @@ const userType = db.type("User", { externalId: db.uuid(), }); -type AttributeMap = { +type Attributes = { role: true; isActive: true; tags: true; @@ -24,10 +26,11 @@ type AttributeMap = { type AttributeList = ["externalId"]; -type AuthInput = AuthServiceInput; +type AuthInput = AuthServiceInput; type MachineUserConfig = NonNullable["admin"]; type AuthSchemaInput = Omit, "name">; +type IsUnknown = unknown extends T ? ([keyof T] extends [never] ? true : false) : false; describe("AuthServiceInput and AuthConfigSchema type alignment", () => { test("aligns top-level keys and optionality with the schema", () => { @@ -60,6 +63,8 @@ describe("AuthServiceInput and AuthConfigSchema type alignment", () => { type AlignedSchemaAttributes = Pick; expectTypeOf().toMatchObjectType(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf().toExtend(); expectTypeOf().toExtend(); expectTypeOf().toExtend< SchemaUserProfile["usernameField"] @@ -76,7 +81,7 @@ describe("AuthServiceInput and AuthConfigSchema type alignment", () => { type SchemaAttributeList = SchemaMachineUser["attributeList"]; type FunctionMachineUser = MachineUserConfig; - type FunctionAttributeKeys = keyof AttributeMap; + type FunctionAttributeKeys = keyof Attributes; type FunctionAttributeValues = FunctionMachineUser["attributes"][FunctionAttributeKeys]; type FunctionAttributeList = FunctionMachineUser["attributeList"]; @@ -108,7 +113,7 @@ describe("AuthServiceInput and AuthConfigSchema type alignment", () => { }); // Fixture with an optional user field to cover optionality mirroring -const mixedUserType = db.type("MixedUser", { +const mixedUserType = db.table("MixedUser", { email: db.string().unique(), role: db.string(), nickname: db.string({ optional: true }), @@ -343,4 +348,87 @@ describe("AuthConfigSchema userProfile/machineUserAttributes validation", () => const result = AuthConfigSchema.parse(config); expect(result.userProfile?.namespace).toBe("external-ns"); }); + + test("strips TailorDB type builder helpers from userProfile.type", () => { + const result = AuthConfigSchema.parse({ + name: "my-auth", + userProfile: { + type: userType, + usernameField: "email", + }, + }); + + expect(result.userProfile?.type).toMatchObject({ + name: "User", + fields: expect.any(Object), + }); + expect(result.userProfile?.type).not.toHaveProperty("hooks"); + expect(result.userProfile?.type).not.toHaveProperty("_output"); + }); + + test("rejects unbranded userProfile.type copies with unknown keys", () => { + const result = AuthConfigSchema.safeParse({ + name: "my-auth", + userProfile: { + type: { ...userType, unknownOption: true }, + usernameField: "email", + }, + }); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected AuthConfigSchema parsing to fail"); + } + expect(JSON.stringify(result.error.issues)).toContain("unknownOption"); + }); + + test("omits unknown outer userProfile.type keys with TailorDB builder helpers", () => { + const typeWithUnknownKey = brandValue( + { + ...userType, + unknownOption: true, + }, + "tailordb-type", + ); + + const result = AuthConfigSchema.parse({ + name: "my-auth", + userProfile: { + type: typeWithUnknownKey, + usernameField: "email", + }, + }); + + expect(result.userProfile?.type).not.toHaveProperty("unknownOption"); + }); + + test("rejects invalid TailorDB fields in userProfile.type", () => { + const typeWithInvalidField = brandValue( + { + ...userType, + fields: { + ...userType.fields, + unsupported: { + type: "unsupported", + metadata: {}, + }, + }, + }, + "tailordb-type", + ); + + const result = AuthConfigSchema.safeParse({ + name: "my-auth", + userProfile: { + type: typeWithInvalidField, + usernameField: "email", + }, + }); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected AuthConfigSchema parsing to fail"); + } + expect(JSON.stringify(result.error.issues)).toContain("unsupported"); + }); }); diff --git a/packages/sdk/src/parser/service/auth/schema.ts b/packages/sdk/src/parser/service/auth/schema.ts index 67a08aa81..2b405eca2 100644 --- a/packages/sdk/src/parser/service/auth/schema.ts +++ b/packages/sdk/src/parser/service/auth/schema.ts @@ -1,9 +1,12 @@ import { z } from "zod"; import { AuthConnectionConfigSchema } from "#/parser/service/auth-connection/index"; import { TailorFieldSchema } from "#/parser/service/field/schema"; +import { stripTailorDBTypeBuilderHelpers } from "#/parser/service/tailordb/builder-helpers"; +import { TailorDBTypeSchema } from "#/parser/service/tailordb/index"; import type { ValueOperand } from "#/configure/services/auth/types"; +import type { TailorDBInstance } from "#/configure/services/tailordb/types"; -export const AuthInvokerObjectSchema = z.object({ +export const AuthInvokerObjectSchema = z.strictObject({ namespace: z.string().describe("Auth namespace"), machineUserName: z.string().describe("Machine user name for authentication"), }); @@ -13,12 +16,12 @@ export const AuthInvokerSchema = z.union([ AuthInvokerObjectSchema, ]); -const secretValueSchema = z.object({ +const secretValueSchema = z.strictObject({ vaultName: z.string().describe("Vault name containing the secret"), secretKey: z.string().describe("Key of the secret in the vault"), }); -export const OIDCSchema = z.object({ +export const OIDCSchema = z.strictObject({ name: z.string().describe("Identity provider name"), kind: z.literal("OIDC"), clientID: z.string().describe("OAuth2 client ID"), @@ -29,7 +32,7 @@ export const OIDCSchema = z.object({ }); export const SAMLSchema = z - .object({ + .strictObject({ name: z.string().describe("Identity provider name"), kind: z.literal("SAML"), enableSignRequest: z.boolean().default(false).describe("Enable signing of SAML requests"), @@ -46,13 +49,14 @@ export const SAMLSchema = z .optional() .describe("URL to redirect to when SAML ACS receives a response with an empty RelayState."), }) + .refine((value) => { const hasMetadata = value.metadataURL !== undefined; const hasRaw = value.rawMetadata !== undefined; return hasMetadata !== hasRaw; }, "Provide either metadataURL or rawMetadata"); -export const IDTokenSchema = z.object({ +export const IDTokenSchema = z.strictObject({ name: z.string().describe("Identity provider name"), kind: z.literal("IDToken"), providerURL: z.string().describe("ID token provider URL"), @@ -61,7 +65,7 @@ export const IDTokenSchema = z.object({ usernameClaim: z.string().optional().describe("JWT claim to use as username"), }); -export const BuiltinIdPSchema = z.object({ +export const BuiltinIdPSchema = z.strictObject({ name: z.string().describe("Identity provider name"), kind: z.literal("BuiltInIdP"), namespace: z.string().describe("IdP namespace"), @@ -80,7 +84,7 @@ export const OAuth2ClientGrantTypeSchema = z .describe("OAuth2 grant type"); export const OAuth2ClientSchema = z - .object({ + .strictObject({ description: z.string().optional().describe("Client description"), grantTypes: z .array(OAuth2ClientGrantTypeSchema) @@ -121,12 +125,13 @@ export const OAuth2ClientSchema = z .optional() .describe("Require DPoP (Demonstrating Proof-of-Possession) for token requests"), }) + .refine((data) => !(data.clientType === "browser" && data.requireDpop === true), { message: "requireDpop cannot be set to true for browser clients as they don't support DPoP", path: ["requireDpop"], }); -export const SCIMAuthorizationSchema = z.object({ +export const SCIMAuthorizationSchema = z.strictObject({ type: z.union([z.literal("oauth2"), z.literal("bearer")]).describe("SCIM authorization type"), bearerSecret: secretValueSchema .optional() @@ -143,7 +148,7 @@ export const SCIMAttributeTypeSchema = z ]) .describe("SCIM attribute data type"); -export const SCIMAttributeSchema = z.object({ +export const SCIMAttributeSchema = z.strictObject({ type: SCIMAttributeTypeSchema.describe("Attribute data type"), name: z.string().describe("Attribute name"), description: z.string().optional().describe("Attribute description"), @@ -163,17 +168,17 @@ export const SCIMAttributeSchema = z.object({ }, }); -const SCIMSchemaSchema = z.object({ +const SCIMSchemaSchema = z.strictObject({ name: z.string().describe("SCIM schema name"), attributes: z.array(SCIMAttributeSchema).describe("Schema attributes"), }); -export const SCIMAttributeMappingSchema = z.object({ +export const SCIMAttributeMappingSchema = z.strictObject({ tailorDBField: z.string().describe("TailorDB field name to map to"), scimPath: z.string().describe("SCIM attribute path"), }); -export const SCIMResourceSchema = z.object({ +export const SCIMResourceSchema = z.strictObject({ name: z.string().describe("SCIM resource name"), tailorDBNamespace: z.string().describe("TailorDB namespace for the resource"), tailorDBType: z.string().describe("TailorDB type name for the resource"), @@ -181,34 +186,24 @@ export const SCIMResourceSchema = z.object({ attributeMapping: z.array(SCIMAttributeMappingSchema).describe("Attribute mapping configuration"), }); -export const SCIMSchema = z.object({ +export const SCIMSchema = z.strictObject({ machineUserName: z.string().describe("Machine user name for SCIM operations"), authorization: SCIMAuthorizationSchema.describe("SCIM authorization configuration"), resources: z.array(SCIMResourceSchema).describe("SCIM resource definitions"), }); -export const TenantProviderSchema = z.object({ +export const TenantProviderSchema = z.strictObject({ namespace: z.string().describe("TailorDB namespace for the tenant type"), type: z.string().describe("TailorDB type name for tenants"), signatureField: z.string().describe("Field used as the tenant signature"), }); -const UserProfileSchema = z.object({ +const UserProfileSchema = z.strictObject({ namespace: z.string().optional().describe("TailorDB namespace where the user type is defined"), - // FIXME: improve TailorDBInstance schema validation - type: z.object({ - name: z.string(), - fields: z.any(), - metadata: z.any(), - hooks: z.any(), - validate: z.any(), - features: z.any(), - indexes: z.any(), - files: z.any(), - permission: z.any(), - gqlPermission: z.any(), - _output: z.any(), - }), + type: z + .custom() + .transform(stripTailorDBTypeBuilderHelpers) + .pipe(TailorDBTypeSchema), usernameField: z.string(), attributes: z.record(z.string(), z.literal(true)).optional(), attributeList: z.array(z.string()).optional(), @@ -221,7 +216,7 @@ const ValueOperandSchema: z.ZodType = z.union([ z.array(z.boolean()), ]); -const MachineUserSchema = z.object({ +const MachineUserSchema = z.strictObject({ // null/undefined values mean "attribute not set" and are dropped so // downstream (deploy, drift diff) only ever sees concrete values. attributes: z @@ -238,15 +233,15 @@ const MachineUserSchema = z.object({ attributeList: z.array(z.uuid()).optional(), }); -const BeforeLoginHookSchema = z.object({ +const BeforeLoginHookSchema = z.strictObject({ handler: z.function(), invoker: z.string(), }); -const AuthConfigBaseSchema = z.object({ +const AuthConfigBaseSchema = z.strictObject({ name: z.string().describe("Auth service name"), hooks: z - .object({ + .strictObject({ beforeLogin: BeforeLoginHookSchema.optional().describe("Before login auth hook"), }) .optional() diff --git a/packages/sdk/src/parser/service/common.ts b/packages/sdk/src/parser/service/common.ts index 1012c14c5..405b628d9 100644 --- a/packages/sdk/src/parser/service/common.ts +++ b/packages/sdk/src/parser/service/common.ts @@ -2,4 +2,6 @@ import { z } from "zod"; // Use `z.custom` instead of `z.function`, since `z.function` changes `toString` representation. // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -export const functionSchema = z.custom((val) => typeof val === "function"); +export const functionSchema: z.ZodType = z.custom( + (val) => typeof val === "function", +); diff --git a/packages/sdk/src/parser/service/executor/schema.test.ts b/packages/sdk/src/parser/service/executor/schema.test.ts index d50d01480..1920d2ff8 100644 --- a/packages/sdk/src/parser/service/executor/schema.test.ts +++ b/packages/sdk/src/parser/service/executor/schema.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, test } from "vitest"; -import { ExecutorSchema, GqlOperationSchema, WorkflowOperationSchema } from "./schema"; +import { describe, expect, expectTypeOf, test } from "vitest"; +import { + ExecutorSchema, + FunctionOperationSchema, + GqlOperationSchema, + WorkflowOperationSchema, +} from "./schema"; +import type { Executor, ExecutorInput, WorkflowOperationArgs } from "#/types/executor.generated"; function expectParseSuccess( result: { success: true; data: T } | { success: false; error: unknown }, @@ -11,6 +17,43 @@ function expectParseSuccess( return result.data; } +function expectParseFailure( + result: { success: true; data: T } | { success: false; error: { issues: unknown[] } }, +): { issues: unknown[] } { + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected schema parsing to fail"); + } + return result.error; +} + +function expectUnknownKeyRejected( + result: { success: true; data: T } | { success: false; error: { issues: unknown[] } }, +) { + const error = expectParseFailure(result); + expect(error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unrecognized_keys", + }), + ]), + ); +} + +describe("FunctionOperationSchema", () => { + test("rejects unknown options", () => { + expect.hasAssertions(); + + expectUnknownKeyRejected( + FunctionOperationSchema.safeParse({ + kind: "function", + body: () => {}, + unknownOption: true, + }), + ); + }); +}); + describe("GqlOperationSchema", () => { const documentNode = { kind: "Document", @@ -26,6 +69,18 @@ describe("GqlOperationSchema", () => { const data = expectParseSuccess(result); expect(data.query).toBe("query { users { id } }"); }); + + test("rejects unknown options", () => { + expect.hasAssertions(); + + expectUnknownKeyRejected( + GqlOperationSchema.safeParse({ + kind: "graphql", + query: "query { users { id } }", + unknownOption: true, + }), + ); + }); }); describe("WorkflowOperationSchema", () => { @@ -41,6 +96,29 @@ describe("WorkflowOperationSchema", () => { expect(data).not.toHaveProperty("workflow"); }); + test("prefers workflow object name when workflowName is also present", () => { + const result = WorkflowOperationSchema.safeParse({ + kind: "workflow", + workflowName: "stale-workflow", + workflow: { name: "current-workflow" }, + args: { id: "123" }, + }); + + const data = expectParseSuccess(result); + expect(data.workflowName).toBe("current-workflow"); + }); + + test("rejects a malformed workflow object even when workflowName is present", () => { + const result = WorkflowOperationSchema.safeParse({ + kind: "workflow", + workflowName: "stale-workflow", + workflow: {}, + args: { id: "123" }, + }); + + expect(result.success).toBe(false); + }); + test("accepts workflowName directly", () => { const result = WorkflowOperationSchema.safeParse({ kind: "workflow", @@ -51,6 +129,146 @@ describe("WorkflowOperationSchema", () => { const data = expectParseSuccess(result); expect(data.workflowName).toBe("my-workflow"); }); + + test("rejects unknown options", () => { + expect.hasAssertions(); + + const error = expectParseFailure( + WorkflowOperationSchema.safeParse({ + kind: "workflow", + workflowName: "my-workflow", + unknownOption: true, + }), + ); + + expect(error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "invalid_union", + errors: expect.arrayContaining([ + expect.arrayContaining([ + expect.objectContaining({ + code: "unrecognized_keys", + }), + ]), + ]), + }), + ]), + ); + }); + + test.each([ + ["string", "hello"], + ["empty string", ""], + ["number", 42], + ["zero", 0], + ["boolean", true], + ["false", false], + ["array", ["hello", 42, false, null]], + ])("accepts %s static args", (_description, args) => { + const result = WorkflowOperationSchema.safeParse({ + kind: "workflow", + workflowName: "my-workflow", + args, + }); + + const data = expectParseSuccess(result); + expect(data.args).toEqual(args); + }); + + test.each([ + ["top-level null", null], + ["Date", new Date("2026-01-01T00:00:00.000Z")], + ["Map", new Map([["key", "value"]])], + ["nested undefined", { nested: undefined }], + ["nested Date", { nested: new Date("2026-01-01T00:00:00.000Z") }], + ["nested Map", [new Map([["key", "value"]])]], + ])("rejects unsupported %s static args", (_description, args) => { + const result = WorkflowOperationSchema.safeParse({ + kind: "workflow", + workflowName: "my-workflow", + args, + }); + + expect(result.success).toBe(false); + }); + + test("accepts a dynamic args function", () => { + const args = () => ({ orderId: "123" }); + const result = WorkflowOperationSchema.safeParse({ + kind: "workflow", + workflowName: "my-workflow", + args, + }); + + const data = expectParseSuccess(result); + expect(data.args).toBe(args); + }); + + test("returns the validated copy of static args", () => { + let reads = 0; + const args = { + get value() { + reads += 1; + return "stable"; + }, + }; + const result = WorkflowOperationSchema.safeParse({ + kind: "workflow", + workflowName: "my-workflow", + args, + }); + + const data = expectParseSuccess(result); + expect(data.args).not.toBe(args); + expect(data.args).toEqual({ value: "stable" }); + const readsAfterParse = reads; + expect(JSON.stringify(data.args)).toBe('{"value":"stable"}'); + expect(reads).toBe(readsAfterParse); + }); + + test("keeps generated Executor workflow args aligned with the generated contract", () => { + type ExecutorWorkflowArgs = Extract["args"]; + + expectTypeOf().toEqualTypeOf(); + + // @ts-expect-error Date is neither a JSON-compatible input nor an args callback. + const invalidArgs: ExecutorWorkflowArgs = new Date(); + void invalidArgs; + }); + + test("keeps generated workflow operation input aligned with reference parsing", () => { + type WorkflowReferenceInput = Extract< + ExecutorInput["operation"], + { kind: "workflow"; workflow: unknown } + >; + + expectTypeOf().not.toBeAny(); + expectTypeOf().not.toBeUnknown(); + expectTypeOf().not.toBeNever(); + + const executor: ExecutorInput = { + name: "test-executor", + trigger: { kind: "schedule", cron: "0 12 * * *" }, + operation: { + kind: "workflow", + workflow: { name: "my-workflow" }, + args: { orderId: "123" }, + }, + }; + + const invalidExecutor: ExecutorInput = { + ...executor, + operation: { + kind: "workflow", + // @ts-expect-error Workflow references require a name. + workflow: {}, + }, + }; + void invalidExecutor; + + expect(executor.operation.kind).toBe("workflow"); + }); }); describe("ExecutorSchema", () => { @@ -101,4 +319,23 @@ describe("ExecutorSchema", () => { } expect(data.operation.query).toBe("mutation { createUser { id } }"); }); + + test("rejects unknown options on operations", () => { + expect.hasAssertions(); + + expectParseFailure( + ExecutorSchema.safeParse({ + name: "test-executor", + trigger: { + kind: "schedule", + cron: "0 12 * * *", + }, + operation: { + kind: "function", + body: () => {}, + unknownOption: true, + }, + }), + ); + }); }); diff --git a/packages/sdk/src/parser/service/executor/schema.ts b/packages/sdk/src/parser/service/executor/schema.ts index d5fd47d7e..3c56dc80b 100644 --- a/packages/sdk/src/parser/service/executor/schema.ts +++ b/packages/sdk/src/parser/service/executor/schema.ts @@ -1,8 +1,9 @@ import { z } from "zod"; import { AuthInvokerSchema } from "../auth"; import { functionSchema } from "../common"; +import type { JsonValue } from "#/types/helpers"; -export const TailorDBTriggerSchema = z.object({ +export const TailorDBTriggerSchema = z.strictObject({ kind: z.literal("tailordb").describe("TailorDB record event trigger"), events: z .array( @@ -19,13 +20,13 @@ export const TailorDBTriggerSchema = z.object({ condition: functionSchema.optional().describe("Condition function to filter events"), }); -export const ResolverExecutedTriggerSchema = z.object({ +export const ResolverExecutedTriggerSchema = z.strictObject({ kind: z.literal("resolverExecuted"), resolverName: z.string().describe("Name of the resolver to trigger on"), condition: functionSchema.optional().describe("Condition function to filter events"), }); -export const ScheduleTriggerSchema = z.object({ +export const ScheduleTriggerSchema = z.strictObject({ kind: z.literal("schedule"), cron: z.string().describe("CRON expression for the schedule"), timezone: z @@ -35,17 +36,17 @@ export const ScheduleTriggerSchema = z.object({ .describe("Timezone for the CRON schedule (default: UTC)"), }); -export const IncomingWebhookTriggerResponseSchema = z.object({ +export const IncomingWebhookTriggerResponseSchema = z.strictObject({ body: functionSchema.optional().describe("Function returning the response body"), statusCode: z.number().int().optional().describe("HTTP status code for the response"), }); -export const IncomingWebhookTriggerSchema = z.object({ +export const IncomingWebhookTriggerSchema = z.strictObject({ kind: z.literal("incomingWebhook"), response: IncomingWebhookTriggerResponseSchema.optional().describe("Response configuration"), }); -export const IdpUserTriggerSchema = z.object({ +export const IdpUserTriggerSchema = z.strictObject({ kind: z.literal("idpUser").describe("IdP user event trigger"), events: z .array(z.enum(["idp.user.created", "idp.user.updated", "idp.user.deleted"])) @@ -60,7 +61,7 @@ export const IdpUserTriggerSchema = z.object({ ), }); -export const AuthAccessTokenTriggerSchema = z.object({ +export const AuthAccessTokenTriggerSchema = z.strictObject({ kind: z.literal("authAccessToken").describe("Auth access token event trigger"), events: z .array( @@ -84,64 +85,95 @@ export const TriggerSchema = z.discriminatedUnion("kind", [ AuthAccessTokenTriggerSchema, ]); -export const FunctionOperationSchema = z.object({ +export const FunctionOperationSchema = z.strictObject({ kind: z.enum(["function", "jobFunction"]), body: functionSchema.describe("Function implementation"), - authInvoker: AuthInvokerSchema.optional().describe("Auth invoker for the function execution"), + invoker: AuthInvokerSchema.optional().describe("Invoker for the function execution"), }); -export const GqlOperationSchema = z.object({ +export const GqlOperationSchema = z.strictObject({ kind: z.literal("graphql"), appName: z.string().optional().describe("Target application name for the GraphQL query"), query: z.preprocess((val) => String(val), z.string().describe("GraphQL query string")), variables: functionSchema.optional().describe("Function to compute GraphQL variables"), - authInvoker: AuthInvokerSchema.optional().describe("Auth invoker for the GraphQL execution"), + invoker: AuthInvokerSchema.optional().describe("Invoker for the GraphQL execution"), }); -export const WebhookOperationSchema = z.object({ +export const WebhookOperationSchema = z.strictObject({ kind: z.literal("webhook"), url: functionSchema.describe("Function returning the webhook URL"), requestBody: functionSchema.optional().describe("Function to compute the request body"), headers: z - .record(z.string(), z.union([z.string(), z.object({ vault: z.string(), key: z.string() })])) + .record( + z.string(), + z.union([z.string(), z.strictObject({ vault: z.string(), key: z.string() })]), + ) .optional() .describe("HTTP headers for the webhook request"), }); -export const WorkflowOperationSchema = z.preprocess( - (val) => { - if ( - val == null || - typeof val !== "object" || - !("workflow" in val) || - typeof val.workflow !== "object" || - val.workflow === null - ) { - return val; - } - - const { workflow, ...rest } = val as { workflow: { name: string } }; - return { ...rest, workflowName: workflow.name }; - }, - z.object({ - kind: z.literal("workflow"), - workflowName: z.string().describe("Name of the workflow to execute"), - args: z - .union([z.record(z.string(), z.unknown()), functionSchema]) - .optional() - .describe("Arguments to pass to the workflow"), - authInvoker: AuthInvokerSchema.optional().describe("Auth invoker for the workflow execution"), - }), +export const JsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(JsonValueSchema), + z.record(z.string(), JsonValueSchema), + ]), ); -const OperationSchema = z.union([ +export const WorkflowInputSchema: z.ZodType< + Exclude, + Exclude +> = z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(JsonValueSchema), + z.record(z.string(), JsonValueSchema), +]); + +export const WorkflowOperationArgsSchema = z + .union([WorkflowInputSchema, functionSchema]) + .describe("Arguments to pass to the workflow"); + +const workflowOperationShape = { + kind: z.literal("workflow"), + args: WorkflowOperationArgsSchema.optional(), + invoker: AuthInvokerSchema.optional().describe("Invoker for the workflow execution"), +}; + +const WorkflowOperationByNameSchema = z.strictObject({ + ...workflowOperationShape, + workflowName: z.string().describe("Name of the workflow to execute"), + workflow: z.never().optional(), +}); + +const WorkflowOperationByReferenceSchema = z + .strictObject({ + ...workflowOperationShape, + workflow: z.looseObject({ name: z.string() }), + workflowName: z.string().optional(), + }) + .transform(({ workflow, ...operation }) => ({ + ...operation, + workflowName: workflow.name, + })); + +export const WorkflowOperationSchema = z.union([ + WorkflowOperationByReferenceSchema, + WorkflowOperationByNameSchema, +]); + +export const OperationSchema = z.union([ FunctionOperationSchema, GqlOperationSchema, WebhookOperationSchema, WorkflowOperationSchema, ]); -export const ExecutorSchema = z.object({ +export const ExecutorSchema = z.strictObject({ name: z.string().describe("Executor name"), description: z.string().optional().describe("Executor description"), disabled: z.boolean().optional().default(false).describe("Whether the executor is disabled"), diff --git a/packages/sdk/src/parser/service/field/schema.ts b/packages/sdk/src/parser/service/field/schema.ts index 8686c02ae..2ee981969 100644 --- a/packages/sdk/src/parser/service/field/schema.ts +++ b/packages/sdk/src/parser/service/field/schema.ts @@ -15,26 +15,29 @@ const TailorFieldTypeSchema = z.enum([ "nested", ]); -const AllowedValueSchema = z.object({ +const AllowedValueSchema = z.strictObject({ value: z.string().describe("The allowed value"), description: z.string().optional().describe("Description of the allowed value"), }); -const FieldMetadataSchema = z.object({ +const FieldMetadataSchema = z.strictObject({ required: z.boolean().optional().describe("Whether the field is required"), array: z.boolean().optional().describe("Whether the field is an array"), description: z.string().optional().describe("Field description"), allowedValues: z.array(AllowedValueSchema).optional().describe("Allowed values for enum fields"), hooks: z - .object({ + .strictObject({ create: functionSchema.optional().describe("Hook function called on creation"), update: functionSchema.optional().describe("Hook function called on update"), }) .optional() .describe("Lifecycle hooks"), + validate: z.array(functionSchema).optional().describe("Validation functions for the field"), typeName: z.string().optional().describe("Type name for nested or enum fields"), + default: z.unknown().optional().describe("Default value for the field on create"), }); +// strip unknown keys export const TailorFieldSchema = z.object({ type: TailorFieldTypeSchema.describe("Field data type"), metadata: FieldMetadataSchema.describe("Field metadata configuration"), diff --git a/packages/sdk/src/parser/service/http-adapter/schema.ts b/packages/sdk/src/parser/service/http-adapter/schema.ts index f1f5deed5..ddd2b7e76 100644 --- a/packages/sdk/src/parser/service/http-adapter/schema.ts +++ b/packages/sdk/src/parser/service/http-adapter/schema.ts @@ -11,6 +11,7 @@ const inputHandlersSchema = z patch: functionSchema.optional().describe("Handler for PATCH requests"), delete: functionSchema.optional().describe("Handler for DELETE requests"), }) + .refine( // optional fields become undefined after zod parses them // oxlint-disable-next-line typescript/no-unnecessary-condition @@ -44,4 +45,5 @@ export const HttpAdapterConfigSchema = z .optional() .describe("Function that transforms GraphQL response to HTTP response"), }) + .brand("HttpAdapterConfig"); diff --git a/packages/sdk/src/parser/service/idp/schema.ts b/packages/sdk/src/parser/service/idp/schema.ts index 9b4bc802b..5eece0c13 100644 --- a/packages/sdk/src/parser/service/idp/schema.ts +++ b/packages/sdk/src/parser/service/idp/schema.ts @@ -41,7 +41,7 @@ function normalizeIdPGqlOperations( export const IdPGqlOperationsSchema = z .union([ z.literal("query"), - z.object({ + z.strictObject({ create: z.boolean().optional().describe("Enable _createUser mutation (default: true)"), update: z.boolean().optional().describe("Enable _updateUser mutation (default: true)"), delete: z.boolean().optional().describe("Enable _deleteUser mutation (default: true)"), @@ -74,7 +74,7 @@ const allowedReturnOriginPattern = /^(https?:\/\/[a-zA-Z0-9.-]+(:[0-9]+)?|[a-z0-9][a-z0-9-]{1,61}[a-z0-9]:url)$/; export const IdPUserAuthPolicySchema = z - .object({ + .strictObject({ useNonEmailIdentifier: z .boolean() .optional() @@ -151,6 +151,7 @@ export const IdPUserAuthPolicySchema = z .optional() .describe("Label shown next to the user account in authenticator apps"), }) + .refine( (data) => data.passwordMinLength === undefined || @@ -240,12 +241,13 @@ const emailFieldSchema = z .regex(/^[^\r\n]*$/, "must not contain newline characters"); export const IdPEmailConfigSchema = z - .object({ + .strictObject({ fromName: emailFieldSchema.optional().describe("Default sender display name for emails"), passwordResetSubject: emailFieldSchema .optional() .describe("Default subject for password reset emails"), }) + .describe("Namespace-level email configuration defaults"); const IdPPermissionOperandSchema = z.union([ @@ -253,10 +255,10 @@ const IdPPermissionOperandSchema = z.union([ z.boolean(), z.array(z.string()).readonly(), z.array(z.boolean()).readonly(), - z.object({ user: z.string() }), - z.object({ idpUser: z.enum(["id", "name", "disabled"]) }), - z.object({ oldIdpUser: z.enum(["id", "name", "disabled"]) }), - z.object({ newIdpUser: z.enum(["id", "name", "disabled"]) }), + z.strictObject({ user: z.string() }), + z.strictObject({ idpUser: z.enum(["id", "name", "disabled"]) }), + z.strictObject({ oldIdpUser: z.enum(["id", "name", "disabled"]) }), + z.strictObject({ newIdpUser: z.enum(["id", "name", "disabled"]) }), ]); const IdPPermissionOperatorSchema = z.enum(["=", "!=", "in", "not in"]); @@ -267,7 +269,7 @@ const IdPPermissionConditionSchema = z const IdPActionPermissionSchema = z.union([ // Object format: { conditions, description?, permit? } - z.object({ + z.strictObject({ conditions: z.union([ IdPPermissionConditionSchema, z.array(IdPPermissionConditionSchema).readonly(), @@ -302,7 +304,7 @@ const IdPActionPermissionSchema = z.union([ ]); export const IdPPermissionSchema = z - .object({ + .strictObject({ create: z.array(IdPActionPermissionSchema).readonly(), read: z.array(IdPActionPermissionSchema).readonly(), update: z.array(IdPActionPermissionSchema).readonly(), @@ -310,13 +312,14 @@ export const IdPPermissionSchema = z sendPasswordResetEmail: z.array(IdPActionPermissionSchema).readonly().optional(), unenrollMfa: z.array(IdPActionPermissionSchema).readonly().optional(), }) + .describe("Per-operation permission policies for IdP users"); export const IdPSchema = z - .object({ + .strictObject({ name: z.string().describe("IdP service name"), authorization: z - .union([z.literal("insecure"), z.literal("loggedIn"), z.object({ cel: z.string() })]) + .union([z.literal("insecure"), z.literal("loggedIn"), z.strictObject({ cel: z.string() })]) .optional() .describe("Authorization mode for IdP API access"), clients: z.array(z.string()).describe("OAuth2 client names that can use this IdP"), @@ -339,6 +342,7 @@ export const IdPSchema = z "Per-operation permission policies for IdP users", ), }) + .refine( (data) => !data.userAuthPolicy?.enableMfa || diff --git a/packages/sdk/src/parser/service/resolver/schema.test.ts b/packages/sdk/src/parser/service/resolver/schema.test.ts new file mode 100644 index 000000000..fe2c189d7 --- /dev/null +++ b/packages/sdk/src/parser/service/resolver/schema.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest"; +import { ResolverSchema } from "./schema"; +import type { ZodError } from "zod"; + +function expectParseFailure( + result: { success: true; data: T } | { success: false; error: ZodError }, +): ZodError { + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected schema parsing to fail"); + } + return result.error; +} + +describe("ResolverSchema", () => { + const validResolver = { + operation: "query", + name: "getUser", + body: () => {}, + output: { + type: "string", + fields: {}, + metadata: {}, + }, + }; + + test("rejects unknown options", () => { + const error = expectParseFailure( + ResolverSchema.safeParse({ + ...validResolver, + unknownOption: true, + }), + ); + + expect(error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unrecognized_keys", + }), + ]), + ); + }); +}); diff --git a/packages/sdk/src/parser/service/resolver/schema.ts b/packages/sdk/src/parser/service/resolver/schema.ts index e909bbff5..d6a6ad217 100644 --- a/packages/sdk/src/parser/service/resolver/schema.ts +++ b/packages/sdk/src/parser/service/resolver/schema.ts @@ -7,7 +7,7 @@ export const QueryTypeSchema = z .union([z.literal("query"), z.literal("mutation")]) .describe("GraphQL operation type"); -export const ResolverSchema = z.object({ +export const ResolverSchema = z.strictObject({ operation: QueryTypeSchema.describe("GraphQL operation type (query or mutation)"), name: z.string().describe("Resolver name"), description: z.string().optional().describe("Resolver description"), @@ -15,5 +15,5 @@ export const ResolverSchema = z.object({ body: functionSchema.describe("Resolver implementation function"), output: TailorFieldSchema.describe("Output field definition"), publishEvents: z.boolean().optional().describe("Enable publishing events from this resolver"), - authInvoker: AuthInvokerSchema.optional().describe("Machine user to execute this resolver as"), + invoker: AuthInvokerSchema.optional().describe("Machine user to execute this resolver as"), }); diff --git a/packages/sdk/src/parser/service/secrets/schema.ts b/packages/sdk/src/parser/service/secrets/schema.ts index 06d0f4f62..e2e462df2 100644 --- a/packages/sdk/src/parser/service/secrets/schema.ts +++ b/packages/sdk/src/parser/service/secrets/schema.ts @@ -4,9 +4,9 @@ const namePattern = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/; const nameSchema = z.string().regex(namePattern); const secretsVaultSchema = z.record(nameSchema, z.string().nullish()); -export const SecretsSchema = z.object({ +export const SecretsSchema = z.strictObject({ vaults: z.record(nameSchema, secretsVaultSchema), - options: z.object({ + options: z.strictObject({ ignoreNullishValues: z.boolean(), }), }); diff --git a/packages/sdk/src/parser/service/staticwebsite/schema.ts b/packages/sdk/src/parser/service/staticwebsite/schema.ts index ee53f0de9..355b8d313 100644 --- a/packages/sdk/src/parser/service/staticwebsite/schema.ts +++ b/packages/sdk/src/parser/service/staticwebsite/schema.ts @@ -1,7 +1,8 @@ import { z } from "zod"; +// strip unknown keys export const StaticWebsiteSchema = z - .object({ + .strictObject({ name: z.string().describe("Static website name"), description: z.string().optional().describe("Static website description"), allowedIpAddresses: z @@ -10,4 +11,5 @@ export const StaticWebsiteSchema = z .describe("IP addresses allowed to access the website"), customDomains: z.array(z.string()).optional().describe("Custom domains for the static website"), }) + .brand("StaticWebsiteConfig"); diff --git a/packages/sdk/src/parser/service/tailordb/builder-helpers.ts b/packages/sdk/src/parser/service/tailordb/builder-helpers.ts new file mode 100644 index 000000000..bcd6cca64 --- /dev/null +++ b/packages/sdk/src/parser/service/tailordb/builder-helpers.ts @@ -0,0 +1,19 @@ +import { isSdkBranded } from "#/utils/brand"; +import { TailorDBTypeSchema } from "./schema"; + +const TAILORDB_TYPE_SCHEMA_KEYS = TailorDBTypeSchema.keyof().options; + +export function stripTailorDBTypeBuilderHelpers(type: unknown): unknown { + if (!isSdkBranded(type, "tailordb-type")) { + return type; + } + + const config: Record = {}; + const input = type as Record; + for (const key of TAILORDB_TYPE_SCHEMA_KEYS) { + if (Object.prototype.hasOwnProperty.call(input, key)) { + config[key] = input[key]; + } + } + return config; +} diff --git a/packages/sdk/src/parser/service/tailordb/field.precompiled.test.ts b/packages/sdk/src/parser/service/tailordb/field.precompiled.test.ts index 87552f9be..108351287 100644 --- a/packages/sdk/src/parser/service/tailordb/field.precompiled.test.ts +++ b/packages/sdk/src/parser/service/tailordb/field.precompiled.test.ts @@ -6,10 +6,10 @@ import { setPrecompiledScriptExpr } from "./hooks-validate-precompiled-expr"; describe("parseFieldConfig precompiled expressions", () => { test("uses precompiled hook expression when attached", () => { - const createHook = ({ value }: { value: string | null }) => value ?? "fallback"; + const createHook = ({ input }: { input: string | null }) => input ?? "fallback"; setPrecompiledScriptExpr(createHook, "PRECOMPILED_HOOK_EXPR"); - const type = db.type("User", { + const type = db.table("User", { email: db.string().hooks({ create: createHook }), }); @@ -20,10 +20,11 @@ describe("parseFieldConfig precompiled expressions", () => { }); test("uses precompiled validate expression when attached", () => { - const validator = ({ value }: { value: string }) => value.length > 0; + const validator = ({ value }: { value: string }) => + value.length <= 0 ? "Must not be empty" : undefined; setPrecompiledScriptExpr(validator, "PRECOMPILED_VALIDATE_EXPR"); - const type = db.type("User", { + const type = db.table("User", { email: db.string().validate(validator), }); diff --git a/packages/sdk/src/parser/service/tailordb/field.test.ts b/packages/sdk/src/parser/service/tailordb/field.test.ts index e551aef14..7785196b3 100644 --- a/packages/sdk/src/parser/service/tailordb/field.test.ts +++ b/packages/sdk/src/parser/service/tailordb/field.test.ts @@ -124,11 +124,11 @@ describe("parseFieldConfig validator expressions", () => { test("normalizes a method-shorthand validator whose body contains an arrow function", () => { // Method shorthand syntax, obtained the same way a user's helper object would produce it. const validators = { - isValid({ value }: { value: string }) { - return [value].map((v) => v.includes("@"))[0] ?? false; + isValid({ value }: { value: string }): string | void { + if (!([value].map((v) => v.includes("@"))[0] ?? false)) return "invalid email"; }, }; - const type = db.type("User", { + const type = db.table("User", { email: db.string().validate(validators.isValid), }); @@ -146,11 +146,11 @@ describe("parseFieldConfig script expression validation", () => { test("throws a clear error when a hook cannot be converted to valid JavaScript", () => { const key = "create"; const hooks = { - [key]({ value }: { value: string | null }) { - return value ?? "generated"; + [key]({ input }: { input: string | null }) { + return input ?? "generated"; }, }; - const type = db.type("User", { + const type = db.table("User", { email: db.string().hooks({ create: hooks[key] }), }); @@ -162,10 +162,10 @@ describe("parseFieldConfig script expression validation", () => { }); test("throws a clear error when a validator cannot be converted to valid JavaScript", () => { - const check = function check({ value }: { value: string }) { - return value.length > 0; + const check = function check({ value }: { value: string }): string | void { + if (value.length === 0) return "must not be empty"; }.bind(null); - const type = db.type("User", { + const type = db.table("User", { email: db.string().validate(check), }); @@ -177,10 +177,10 @@ describe("parseFieldConfig script expression validation", () => { }); test("includes the type and field path in conversion errors from type parsing", () => { - const check = function check({ value }: { value: string }) { - return value.length > 0; + const check = function check({ value }: { value: string }): string | void { + if (value.length === 0) return "must not be empty"; }.bind(null); - const type = db.type("User", { + const type = db.table("User", { email: db.string().validate(check), }); @@ -191,3 +191,42 @@ describe("parseFieldConfig script expression validation", () => { ); }); }); + +describe("parseFieldConfig nested inner field restrictions", () => { + test("throws on .hooks() on nested inner fields", () => { + const field = { + type: "nested" as const, + fields: { + name: { + type: "string" as const, + fields: {}, + rawRelation: undefined, + metadata: { + hooks: { + create: ({ input }: { input: string }) => input, + }, + }, + }, + }, + rawRelation: undefined, + metadata: {}, + }; + + expect(() => + parseFieldConfig(field as never, { typeName: "Test", fieldPath: ["items", "name"] }), + ).toThrow(".hooks() cannot be used on nested inner fields"); + }); + + test("throws on .default() on nested inner fields", () => { + const field = { + type: "string" as const, + fields: {}, + rawRelation: undefined, + metadata: { default: "pending" }, + }; + + expect(() => + parseFieldConfig(field as never, { typeName: "Test", fieldPath: ["items", "status"] }), + ).toThrow(".default() cannot be used on nested inner fields"); + }); +}); diff --git a/packages/sdk/src/parser/service/tailordb/field.ts b/packages/sdk/src/parser/service/tailordb/field.ts index d1ece9aae..ca2826018 100644 --- a/packages/sdk/src/parser/service/tailordb/field.ts +++ b/packages/sdk/src/parser/service/tailordb/field.ts @@ -18,9 +18,98 @@ type ScriptFunction = (...args: never[]) => unknown; type ScriptContextKind = "hooks.create" | "hooks.update" | "validate"; -// Since there's naming difference between platform and sdk, -// use this mapping in all scripts to provide variables that match sdk types. -export const tailorUserMap = /* js */ `{ id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes }`; +const NIL_UUID = "00000000-0000-0000-0000-000000000000"; + +/** + * Per-source field expressions for {@link makePrincipalExpr}. Each value is a JS + * snippet evaluated against the raw server payload bound to `$raw`. + */ +interface PrincipalFieldExprs { + /** + * Raw type accessor. `raw` is matched against `USER_TYPE_*` when normalizing; + * `fallback` (defaulting to `raw`) supplies the type for non-matching values, + * which lets sources whose primary field is empty fall back to a secondary. + */ + type: { raw: string; fallback?: string }; + /** Raw id accessor. */ + id: string; + workspaceId: string; + attributes: string; + attributeList: string; +} + +interface MakePrincipalExprOptions { + source: string; + fields: PrincipalFieldExprs; + normalize: boolean; + requireId?: boolean; +} + +/** + * Build the server→SDK principal mapping expression shared across services. + * + * All principal-bearing call sites (`caller`, `actor`, `invoker`) must agree on + * the `TailorPrincipal | null` shape, so they are generated here rather than + * hand-written per service. + * @param options - Mapping source and field expressions + * @param options.source - Expression yielding the raw server payload (e.g. `user`) + * @param options.fields - Per-source accessors for each principal field + * @param options.normalize - When true, map `USER_TYPE_*` to SDK type literals and + * return `null` for unspecified types or the nil-UUID id; when false, the + * payload is already in SDK shape and only `null` pass-through is applied + * @param options.requireId - When true, missing ids are also mapped to `null` + * @returns A JS expression string evaluating to `TailorPrincipal | null` + */ +export function makePrincipalExpr(options: MakePrincipalExprOptions): string { + const { source, fields, normalize, requireId = false } = options; + const body = /* js */ `{ + id, + type, + workspaceId: ${fields.workspaceId}, + attributes: ${fields.attributes}, + attributeList: ${fields.attributeList}, + }`; + if (!normalize) { + return /* js */ `(($raw) => { + if (!$raw) { + return null; + } + const id = ${fields.id}; + const type = ${fields.type.raw}; + return ${body}; +})(${source})`; + } + const missingIdGuard = requireId ? " || !id" : ""; + return /* js */ `(($raw) => { + if (!$raw) { + return null; + } + const type = ${fields.type.raw} === "USER_TYPE_USER" + ? "user" + : ${fields.type.raw} === "USER_TYPE_MACHINE_USER" + ? "machine_user" + : ${fields.type.fallback ?? fields.type.raw}; + const id = ${fields.id}; + if (!type || type === "USER_TYPE_UNSPECIFIED" || id === "${NIL_UUID}"${missingIdGuard}) { + return null; + } + return ${body}; +})(${source})`; +} + +// Since there's naming difference between platform and SDK, use this mapping in +// all scripts to provide variables that match `TailorPrincipal | null`. +export const tailorPrincipalMap = makePrincipalExpr({ + source: "user", + normalize: true, + fields: { + type: { raw: "$raw?.type" }, + id: "$raw.id", + workspaceId: "$raw.workspace_id ?? $raw.workspaceId", + attributes: "$raw.attribute_map ?? $raw.attributeMap ?? {}", + attributeList: "$raw.attributes ?? []", + }, +}); /** * Parse `wrapped` and return the first property of the top-level parenthesized @@ -53,7 +142,7 @@ const firstObjectProperty = (wrapped: string) => { * @param fn - Function to stringify * @returns Stringified function source */ -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +// oxlint-disable-next-line typescript/no-unsafe-function-type export const stringifyFunction = (fn: Function): string => { const src = fn.toString().trim(); // `src` is already a valid function/arrow expression (e.g. `function () {}`, @@ -109,12 +198,44 @@ const convertToScriptExpr = ( return precompiledExpr; } const normalized = stringifyFunction(fn); + const argsObject = + kind === "validate" + ? `{ value: _value }` + : kind === "hooks.create" + ? `{ input: _value, invoker: ${tailorPrincipalMap}, now: _now }` + : `{ input: _value, oldValue: _oldValue, invoker: ${tailorPrincipalMap}, now: _now }`; return assertParsableExpression( - `(${normalized})({ value: _value, data: _data, user: ${tailorUserMap} })`, + `(${normalized})(${argsObject})`, formatScriptContext(kind, context), ); }; +// oxlint-disable-next-line typescript/no-unsafe-function-type +export const convertTypeHookToExpr = (fn: Function): string => { + const precompiledExpr = getPrecompiledScriptExpr(fn as (...args: never[]) => unknown); + if (precompiledExpr) { + return precompiledExpr; + } + const normalized = stringifyFunction(fn); + return assertParsableExpression( + `(${normalized})({ input: _input, oldRecord: _oldRecord, invoker: ${tailorPrincipalMap}, now: _now })`, + "type-hook", + ); +}; + +// oxlint-disable-next-line typescript/no-unsafe-function-type +export const convertTypeValidateToExpr = (fn: Function): string => { + const precompiledExpr = getPrecompiledScriptExpr(fn as (...args: never[]) => unknown); + if (precompiledExpr) { + return precompiledExpr; + } + const normalized = stringifyFunction(fn); + return assertParsableExpression( + `(${normalized})({ newRecord: _newRecord, oldRecord: _oldRecord, invoker: ${tailorPrincipalMap} }, __issues)`, + "type-validate", + ); +}; + /** * Parse TailorDBField into OperatorFieldConfig. * This transforms user-defined functions into script expressions. @@ -131,6 +252,20 @@ export function parseFieldConfig( // Access rawRelation via getter (if available) const rawRelation = (field as unknown as { rawRelation?: RawRelationConfig }).rawRelation; + if (context && context.fieldPath.length > 1 && metadata.default !== undefined) { + throw new Error( + `Field "${context.fieldPath.join(".")}" on type "${context.typeName}": ` + + `.default() cannot be used on nested inner fields`, + ); + } + + if (context && context.fieldPath.length > 1 && metadata.hooks) { + throw new Error( + `Field "${context.fieldPath.join(".")}" on type "${context.typeName}": ` + + `.hooks() cannot be used on nested inner fields`, + ); + } + const nestedFields = field.fields as Record | undefined; return { type: fieldType, @@ -153,19 +288,12 @@ export function parseFieldConfig( ), } : {}), - validate: metadata.validate?.map((v) => { - const { fn, message } = - typeof v === "function" - ? { fn: v, message: `failed by \`${v.toString().trim()}\`` } - : { fn: v[0], message: v[1] }; - - return { - script: { - expr: convertToScriptExpr(fn, "validate", context), - }, - errorMessage: message, - }; - }), + validate: metadata.validate?.map((fn) => ({ + script: { + expr: convertToScriptExpr(fn, "validate", context), + }, + errorMessage: "", + })), hooks: metadata.hooks ? { create: metadata.hooks.create diff --git a/packages/sdk/src/parser/service/tailordb/index.ts b/packages/sdk/src/parser/service/tailordb/index.ts index 5cb0f781b..2a5692b98 100644 --- a/packages/sdk/src/parser/service/tailordb/index.ts +++ b/packages/sdk/src/parser/service/tailordb/index.ts @@ -1,3 +1,3 @@ -export { stringifyFunction, tailorUserMap } from "./field"; +export { makePrincipalExpr, stringifyFunction, tailorPrincipalMap } from "./field"; export { parseTypes } from "./type-parser"; export { TailorDBServiceConfigSchema, TailorDBTypeSchema } from "./schema"; diff --git a/packages/sdk/src/parser/service/tailordb/relation.ts b/packages/sdk/src/parser/service/tailordb/relation.ts index 3c1766169..1a7c08bc9 100644 --- a/packages/sdk/src/parser/service/tailordb/relation.ts +++ b/packages/sdk/src/parser/service/tailordb/relation.ts @@ -1,4 +1,3 @@ -import * as inflection from "inflection"; import type { RawRelationConfig } from "#/configure/services/tailordb/types"; import type { OperatorFieldConfig } from "#/parser/service/tailordb/types"; import type { UnionToTuple } from "type-fest"; @@ -128,16 +127,9 @@ export function buildRelationInfo( const targetTypeName = rawRelation.toward.type === "self" ? context.typeName : rawRelation.toward.type; - // Compute forward name let forwardName = rawRelation.toward.as; if (!forwardName) { - if (rawRelation.toward.type === "self") { - // For self-relations, derive from field name by removing ID suffix - forwardName = context.fieldName.replace(/(ID|Id|id)$/u, ""); - } else { - // Use inflection to generate default forward name - forwardName = inflection.camelize(targetTypeName, true); - } + forwardName = context.fieldName.replace(/(ID|Id|id)$/u, ""); } return { diff --git a/packages/sdk/src/parser/service/tailordb/schema.ts b/packages/sdk/src/parser/service/tailordb/schema.ts index f14db9bd4..e77d06ec7 100644 --- a/packages/sdk/src/parser/service/tailordb/schema.ts +++ b/packages/sdk/src/parser/service/tailordb/schema.ts @@ -25,7 +25,7 @@ function normalizeGqlOperations( export const GqlOperationsSchema = z .union([ z.literal("query"), - z.object({ + z.strictObject({ create: z.boolean().optional().describe("Enable create mutation (default: true)"), update: z.boolean().optional().describe("Enable update mutation (default: true)"), delete: z.boolean().optional().describe("Enable delete mutation (default: true)"), @@ -54,12 +54,12 @@ const TailorFieldTypeSchema = z.enum([ "nested", ]); -const AllowedValueSchema = z.object({ +const AllowedValueSchema = z.strictObject({ value: z.string(), description: z.string().optional(), }); -export const DBFieldMetadataSchema = z.object({ +export const DBFieldMetadataSchema = z.strictObject({ required: z.boolean().optional().describe("Whether the field is required"), array: z.boolean().optional().describe("Whether the field is an array"), description: z.string().optional().describe("Field description"), @@ -75,18 +75,15 @@ export const DBFieldMetadataSchema = z.object({ foreignKeyType: z.string().optional().describe("Target type name for foreign key relations"), foreignKeyField: z.string().optional().describe("Target field name for foreign key relations"), hooks: z - .object({ + .strictObject({ create: functionSchema.optional().describe("Hook function called on record creation"), update: functionSchema.optional().describe("Hook function called on record update"), }) .optional() .describe("Lifecycle hooks for the field"), - validate: z - .array(z.union([functionSchema, z.tuple([functionSchema, z.string()])])) - .optional() - .describe("Validation functions for the field"), + validate: z.array(functionSchema).optional().describe("Validation functions for the field"), serial: z - .object({ + .strictObject({ start: z.number().describe("Starting value for the serial sequence"), maxValue: z.number().optional().describe("Maximum value for the serial sequence"), format: z.string().optional().describe("Format string for serial value (string type only)"), @@ -100,13 +97,14 @@ export const DBFieldMetadataSchema = z.object({ .max(12) .optional() .describe("Decimal scale (number of digits after decimal point, 0-12)"), + default: z.unknown().optional().describe("Default value for the field on create"), }); const RelationTypeSchema = z.enum(relationTypesKeys); -export const RawRelationConfigSchema = z.object({ +export const RawRelationConfigSchema = z.strictObject({ type: RelationTypeSchema.describe("Relation cardinality type"), - toward: z.object({ + toward: z.strictObject({ type: z.string().describe("Target type name, or 'self' for self-relations"), as: z.string().optional().describe("Custom forward relation name"), key: z.string().optional().describe("Target field to join on (default: 'id')"), @@ -115,6 +113,7 @@ export const RawRelationConfigSchema = z.object({ }); const TailorDBFieldSchema: z.ZodType = z.lazy(() => + // strip unknown keys z.object({ type: TailorFieldTypeSchema, fields: z.record(z.string(), TailorDBFieldSchema).optional(), @@ -127,7 +126,7 @@ const TailorDBFieldSchema: z.ZodType = z.lazy(() => * Schema for TailorDB type settings. * Normalizes gqlOperations from alias ("query") to object format. */ -export const TailorDBTypeSettingsSchema = z.object({ +export const TailorDBTypeSettingsSchema = z.strictObject({ pluralForm: z.string().optional().describe("Custom plural form of the type name for GraphQL"), aggregation: z.boolean().optional().describe("Enable aggregation queries for this type"), bulkUpsert: z.boolean().optional().describe("Enable bulk upsert mutation for this type"), @@ -147,7 +146,7 @@ export const GQL_PERMISSION_INVALID_OPERAND_MESSAGE = const GqlPermissionOperandSchema = z.union( [ - z.object({ user: z.string() }).strict(), + z.strictObject({ user: z.string() }), z.string(), z.boolean(), z.array(z.string()), @@ -169,9 +168,9 @@ const GqlPermissionOperandSchema = z.union( const RecordPermissionOperandSchema = z.union([ GqlPermissionOperandSchema, - z.object({ record: z.string() }), - z.object({ oldRecord: z.string() }), - z.object({ newRecord: z.string() }), + z.strictObject({ record: z.string() }), + z.strictObject({ oldRecord: z.string() }), + z.strictObject({ newRecord: z.string() }), ]); const PermissionOperatorSchema = z.enum(["=", "!=", "in", "not in", "hasAny", "not hasAny"]); @@ -186,7 +185,7 @@ const GqlPermissionConditionSchema = z const ActionPermissionSchema = z.union([ // Object format: { conditions, description?, permit? } - z.object({ + z.strictObject({ conditions: z.union([ RecordPermissionConditionSchema, z.array(RecordPermissionConditionSchema).readonly(), @@ -229,16 +228,16 @@ const GqlPermissionActionSchema = z.enum([ "bulkUpsert", ]); -const GqlPermissionPolicySchema = z.object({ +const GqlPermissionPolicySchema = z.strictObject({ conditions: z.array(GqlPermissionConditionSchema).readonly(), actions: z.union([z.literal("all"), z.array(GqlPermissionActionSchema).readonly()]), permit: z.boolean().optional(), description: z.string().optional(), }); -export const RawPermissionsSchema = z.object({ +export const RawPermissionsSchema = z.strictObject({ record: z - .object({ + .strictObject({ create: z.array(ActionPermissionSchema).readonly(), read: z.array(ActionPermissionSchema).readonly(), update: z.array(ActionPermissionSchema).readonly(), @@ -248,28 +247,38 @@ export const RawPermissionsSchema = z.object({ gql: z.array(GqlPermissionPolicySchema).readonly().optional(), }); -export const TailorDBTypeSchema = z.object({ +export const TailorDBTypeSchema = z.strictObject({ name: z.string(), fields: z.record(z.string(), TailorDBFieldSchema), - metadata: z.object({ - name: z.string(), - description: z.string().optional(), - settings: TailorDBTypeSettingsSchema.optional(), - permissions: RawPermissionsSchema, - files: z.record(z.string(), z.string()), - indexes: z - .record( - z.string(), - z.object({ - fields: z.array(z.string()), - unique: z.boolean().optional(), - }), - ) - .optional(), - }), + // oxlint-disable-next-line zod/prefer-strict-object, tailor-zod/require-object-policy-comment -- Keep z.object().strict() so zinfer preserves the RawPermissions alias in generated types. + metadata: z + .object({ + name: z.string(), + description: z.string().optional(), + settings: TailorDBTypeSettingsSchema.optional(), + permissions: RawPermissionsSchema, + files: z.record(z.string(), z.string()), + indexes: z + .record( + z.string(), + z.strictObject({ + fields: z.array(z.string()), + unique: z.boolean().optional(), + }), + ) + .optional(), + typeHook: z + .strictObject({ + create: functionSchema.optional(), + update: functionSchema.optional(), + }) + .optional(), + typeValidate: functionSchema.optional(), + }) + .strict(), }); -const TailorDBMigrationConfigSchema = z.object({ +const TailorDBMigrationConfigSchema = z.strictObject({ directory: z.string().describe("Directory containing migration files"), machineUser: z.string().optional().describe("Machine user name for migration execution"), }); @@ -278,7 +287,7 @@ const TailorDBMigrationConfigSchema = z.object({ * Schema for TailorDB service configuration. * Normalizes gqlOperations from alias ("query") to object format. */ -export const TailorDBServiceConfigSchema = z.object({ +export const TailorDBServiceConfigSchema = z.strictObject({ files: z.array(z.string()).describe("Glob patterns for TailorDB type definition files"), ignores: z.array(z.string()).optional().describe("Glob patterns to exclude from type discovery"), erdSite: z.string().optional().describe("URL for the ERD (Entity Relationship Diagram) site"), diff --git a/packages/sdk/src/parser/service/tailordb/type-parser.test.ts b/packages/sdk/src/parser/service/tailordb/type-parser.test.ts index 7c48ec048..be78ac659 100644 --- a/packages/sdk/src/parser/service/tailordb/type-parser.test.ts +++ b/packages/sdk/src/parser/service/tailordb/type-parser.test.ts @@ -5,7 +5,7 @@ import { parseTypes } from "./type-parser"; describe("parseTypes", () => { test("allows type names that match Object prototype properties", () => { - const testType = db.type("toString", { + const testType = db.table("toString", { value: db.string(), }); @@ -15,7 +15,7 @@ describe("parseTypes", () => { }); test("allows __proto__ as a type name", () => { - const testType = db.type("__proto__", { + const testType = db.table("__proto__", { value: db.string(), }); @@ -36,7 +36,7 @@ describe("parseTypes", () => { const field = db.string({ array: true }); (field as unknown as { _metadata: Record })._metadata[metadataKey] = true; - const testType = db.type("Test", { + const testType = db.table("Test", { tags: field, }); @@ -49,7 +49,7 @@ describe("parseTypes", () => { ["index", () => db.string().index()], ["unique", () => db.string().unique()], ] as const)("should allow %s on non-array fields", (metadataKey, buildField) => { - const testType = db.type("Test", { + const testType = db.table("Test", { email: buildField(), }); @@ -60,11 +60,11 @@ describe("parseTypes", () => { describe("buildBackwardRelationships", () => { test("should build backward relationships correctly", () => { - const employee = db.type("Employee", { + const employee = db.table("Employee", { name: db.string(), }); - const performanceReview = db.type("PerformanceReview", { + const performanceReview = db.table("PerformanceReview", { employeeId: db.uuid().relation({ type: "n-1", toward: { type: employee }, @@ -88,13 +88,13 @@ describe("parseTypes", () => { }); test("should throw error when backward relation names are duplicated", () => { - const employee = db.type("Employee", { + const employee = db.table("Employee", { name: db.string(), }); // Two fields referencing the same type without explicit backward names // Both will generate "performanceReviews" as the backward name - const performanceReview = db.type("PerformanceReview", { + const performanceReview = db.table("PerformanceReview", { targetEmployeeId: db.uuid().relation({ type: "n-1", toward: { type: employee, as: "targetEmployee" }, @@ -117,12 +117,12 @@ describe("parseTypes", () => { }); test("should not throw error when backward names are explicitly set to be unique", () => { - const employee = db.type("Employee", { + const employee = db.table("Employee", { name: db.string(), }); // Two fields referencing the same type with explicit unique backward names - const performanceReview = db.type("PerformanceReview", { + const performanceReview = db.table("PerformanceReview", { targetEmployeeId: db.uuid().relation({ type: "n-1", toward: { type: employee, as: "targetEmployee" }, @@ -155,11 +155,11 @@ describe("parseTypes", () => { }); test("should include source file information in error message when available", () => { - const employee = db.type("Employee", { + const employee = db.table("Employee", { name: db.string(), }); - const performanceReview = db.type("PerformanceReview", { + const performanceReview = db.table("PerformanceReview", { targetEmployeeId: db.uuid().relation({ type: "n-1", toward: { type: employee, as: "targetEmployee" }, @@ -187,12 +187,12 @@ describe("parseTypes", () => { }); test("should generate default backward names using inflection", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); // No explicit backward name, should generate "posts" (plural of "Post") - const post = db.type("Post", { + const post = db.table("Post", { userId: db.uuid().relation({ type: "n-1", toward: { type: user }, @@ -210,12 +210,12 @@ describe("parseTypes", () => { }); test("should generate singular backward name for unique relations", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); // Unique relation (1-1), should generate singular "profile" - const profile = db.type("Profile", { + const profile = db.table("Profile", { userId: db.uuid().relation({ type: "1-1", toward: { type: user }, @@ -239,7 +239,7 @@ describe("parseTypes", () => { [ "existing field", () => - db.type("User", { + db.table("User", { name: db.string(), posts: db.string({ array: true }), }), @@ -248,7 +248,7 @@ describe("parseTypes", () => { "files field", () => db - .type("User", { + .table("User", { name: db.string(), }) .files({ @@ -261,7 +261,7 @@ describe("parseTypes", () => { const user = buildUser(); // Post's backward relation will generate "posts" which conflicts - const post = db.type("Post", { + const post = db.table("Post", { userId: db.uuid().relation({ type: "n-1", toward: { type: user }, @@ -276,37 +276,114 @@ describe("parseTypes", () => { }); describe("forwardRelationships", () => { + test("should derive default forward relation names from field names", () => { + const unit = db.table("Unit", { + name: db.string(), + }); + + const inventoryExecutionLine = db.table("InventoryExecutionLine", { + unitId: db.uuid().relation({ + type: "n-1", + toward: { type: unit }, + backward: "inventoryExecutionLines", + }), + primaryUnitId: db.uuid().relation({ + type: "n-1", + toward: { type: unit }, + backward: "primaryInventoryExecutionLines", + }), + }); + + const result = parseTypes( + toSchemaOutputs({ Unit: unit, InventoryExecutionLine: inventoryExecutionLine }), + "test-namespace", + ); + + expect(result.InventoryExecutionLine!.forwardRelationships).toMatchObject({ + unit: { + name: "unit", + targetType: "Unit", + targetField: "unitId", + }, + primaryUnit: { + name: "primaryUnit", + targetType: "Unit", + targetField: "primaryUnitId", + }, + }); + }); + + test.each([ + ["assigneeID", "assignee"], + ["assigneeId", "assignee"], + ["assigneeid", "assignee"], + ])("should strip a trailing ID suffix from %s", (fieldName, forwardName) => { + const user = db.table("User", { + name: db.string(), + }); + const task = db.table("Task", { + [fieldName]: db.uuid().relation({ + type: "n-1", + toward: { type: user }, + backward: "tasks", + }), + }); + + const result = parseTypes(toSchemaOutputs({ User: user, Task: task }), "test-namespace"); + + expect(result.Task!.forwardRelationships[forwardName]).toMatchObject({ + name: forwardName, + targetType: "User", + targetField: fieldName, + }); + }); + + test("should require toward.as when a relation field has no ID suffix", () => { + const user = db.table("User", { + email: db.string().unique(), + }); + const profile = db.table("Profile", { + userEmail: db.string().relation({ + type: "1-1", + toward: { type: user, key: "email" }, + }), + }); + + expect(() => + parseTypes(toSchemaOutputs({ User: user, Profile: profile }), "test-namespace"), + ).toThrow(/Forward relation name "userEmail".*same as its own relation field/s); + }); + test("should throw error when forward relation names are duplicated", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - // Two fields referencing the same type without explicit forward names ("as") - // Both will generate "user" as the forward name - const post = db.type("Post", { - authorID: db.uuid().relation({ + // Different supported ID suffixes can still normalize to the same name. + const post = db.table("Post", { + authorId: db.uuid().relation({ type: "n-1", toward: { type: user }, backward: "authoredPosts", }), - reviewerID: db.uuid().relation({ + authorID: db.uuid().relation({ type: "n-1", toward: { type: user }, - backward: "reviewedPosts", + backward: "reviewedAuthoredPosts", }), }); expect(() => parseTypes(toSchemaOutputs({ User: user, Post: post }), "test-namespace"), - ).toThrow(/Forward relation name "user".*duplicated.*authorID.*reviewerID/s); + ).toThrow(/Forward relation name "author".*duplicated.*authorId.*authorID/s); }); test("should not throw error when forward names are explicitly set to be unique", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - const post = db.type("Post", { + const post = db.table("Post", { authorID: db.uuid().relation({ type: "n-1", toward: { type: user, as: "author" }, @@ -336,13 +413,13 @@ describe("parseTypes", () => { }); test("should throw error when forward name conflicts with existing field", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - // Post has a field named "user" - const post = db.type("Post", { - user: db.string(), + // Post has a field named "author" + const post = db.table("Post", { + author: db.string(), authorID: db.uuid().relation({ type: "n-1", toward: { type: user }, @@ -351,36 +428,36 @@ describe("parseTypes", () => { expect(() => parseTypes(toSchemaOutputs({ User: user, Post: post }), "test-namespace"), - ).toThrow(/Forward relation name "user".*conflicts with existing field/s); + ).toThrow(/Forward relation name "author".*conflicts with existing field/s); }); test("should throw error when conflicting field is defined after the relation field", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - // The conflicting "user" field is defined after authorID in the object - const post = db.type("Post", { + // The conflicting "author" field is defined after authorID in the object + const post = db.table("Post", { authorID: db.uuid().relation({ type: "n-1", toward: { type: user }, }), - user: db.string(), + author: db.string(), }); expect(() => parseTypes(toSchemaOutputs({ User: user, Post: post }), "test-namespace"), - ).toThrow(/Forward relation name "user".*conflicts with existing field/s); + ).toThrow(/Forward relation name "author".*conflicts with existing field/s); }); test("should throw error when forward name equals its own relation field name", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); // "as" is set to the same name as the relation field itself: the manifest // would end up with both a scalar field and a relationship named "authorID" - const post = db.type("Post", { + const post = db.table("Post", { authorID: db.uuid().relation({ type: "n-1", toward: { type: user, as: "authorID" }, @@ -393,13 +470,13 @@ describe("parseTypes", () => { }); test("should throw error when forward name conflicts with files field", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); // Post has a files field named "avatar" const post = db - .type("Post", { + .table("Post", { authorID: db.uuid().relation({ type: "n-1", toward: { type: user, as: "avatar" }, @@ -415,11 +492,11 @@ describe("parseTypes", () => { }); test("should throw error when forward name conflicts with backward name", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - const post = db.type("Post", { + const post = db.table("Post", { authorID: db.uuid().relation({ type: "n-1", toward: { type: user }, @@ -427,34 +504,34 @@ describe("parseTypes", () => { }), }); - const comment = db.type("Comment", { + const comment = db.table("Comment", { postID: db.uuid().relation({ type: "n-1", toward: { type: post, as: "post" }, - backward: "user", + backward: "author", }), }); expect(() => parseTypes(toSchemaOutputs({ User: user, Post: post, Comment: comment }), "test-namespace"), - ).toThrow(/Relation name "user" on type "Post".*forward.*backward/s); + ).toThrow(/Relation name "author" on type "Post".*forward.*backward/s); }); test("should include source file information in forward conflict error message", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - const post = db.type("Post", { - authorID: db.uuid().relation({ + const post = db.table("Post", { + authorId: db.uuid().relation({ type: "n-1", toward: { type: user }, backward: "authoredPosts", }), - reviewerID: db.uuid().relation({ + authorID: db.uuid().relation({ type: "n-1", toward: { type: user }, - backward: "reviewedPosts", + backward: "reviewedAuthoredPosts", }), }); @@ -467,11 +544,11 @@ describe("parseTypes", () => { expect(() => parseTypes(toSchemaOutputs({ User: user, Post: post }), "test-namespace", typeSourceInfo), - ).toThrow(/Forward relation name "user".*\/path\/to\/post\.ts/s); + ).toThrow(/Forward relation name "author".*\/path\/to\/post\.ts/s); }); test("should throw error when self relation forward name is empty", () => { - const node = db.type("Node", { + const node = db.table("Node", { ID: db.uuid().relation({ type: "n-1", toward: { type: "self" }, @@ -482,16 +559,32 @@ describe("parseTypes", () => { /Forward relation name for field "ID" on type "Node" cannot be empty/s, ); }); + + test("should throw error when non-self relation forward name is empty", () => { + const user = db.table("User", { + name: db.string(), + }); + const post = db.table("Post", { + ID: db.uuid().relation({ + type: "n-1", + toward: { type: user }, + }), + }); + + expect(() => + parseTypes(toSchemaOutputs({ User: user, Post: post }), "test-namespace"), + ).toThrow(/Forward relation name for field "ID" on type "Post" cannot be empty/s); + }); }); describe("validateRelationType", () => { test("should throw error when relation type is missing", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); // Missing 'type' property - only TypeScript error, need runtime check - const post = db.type("Post", { + const post = db.table("Post", { // @ts-ignore - intentionally missing 'type' to test runtime validation (tsgo/tsc compat) userId: db.uuid().relation({ // @ts-ignore - ignore No overload matches this call error @@ -505,11 +598,11 @@ describe("parseTypes", () => { }); test("should throw error when relation type is invalid", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - const post = db.type("Post", { + const post = db.table("Post", { userId: db.uuid().relation({ // @ts-ignore - intentionally invalid 'type' to test runtime validation (tsgo/tsc compat) type: "invalid-type", @@ -523,11 +616,11 @@ describe("parseTypes", () => { }); test("should throw error when target type does not exist", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - const post = db.type("Post", { + const post = db.table("Post", { userId: db.uuid().relation({ type: "n-1", toward: { type: user }, @@ -543,11 +636,11 @@ describe("parseTypes", () => { test.each(["oneToOne", "1-1", "manyToOne", "n-1", "N-1", "keyOnly"] as const)( "should accept valid relation type %s", (relationType) => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - const post = db.type("Post", { + const post = db.table("Post", { userId: db.uuid().relation({ type: relationType, toward: { type: user }, @@ -563,11 +656,11 @@ describe("parseTypes", () => { describe("processRelation", () => { test("should compute derived metadata for relations", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - const post = db.type("Post", { + const post = db.table("Post", { authorId: db.uuid().relation({ type: "n-1", toward: { type: user, as: "author" }, @@ -589,7 +682,7 @@ describe("parseTypes", () => { test.each([ [ "relation only", - (user: ReturnType) => + (user: ReturnType) => db.uuid().relation({ type: "1-1", toward: { type: user }, @@ -597,7 +690,7 @@ describe("parseTypes", () => { ], [ "unique before relation", - (user: ReturnType) => + (user: ReturnType) => db .uuid() .unique() @@ -607,11 +700,11 @@ describe("parseTypes", () => { }), ], ] as const)("should set unique=true for oneToOne relations (%s)", (_label, buildUserId) => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - const profile = db.type("Profile", { + const profile = db.table("Profile", { userId: buildUserId(user), }); @@ -624,7 +717,7 @@ describe("parseTypes", () => { }); test("should set unique=true for oneToOne relations (unique after relation)", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); const relatedUserId = db.uuid().relation({ @@ -634,7 +727,7 @@ describe("parseTypes", () => { // @ts-expect-error - Testing runtime behavior: 1-1 already implies unique, but we test the call order const userId = relatedUserId.unique(); - const profile = db.type("Profile", { + const profile = db.table("Profile", { userId, }); @@ -649,7 +742,7 @@ describe("parseTypes", () => { test.each([ [ "unique before relation", - (user: ReturnType) => + (user: ReturnType) => db .uuid() .unique() @@ -660,7 +753,7 @@ describe("parseTypes", () => { ], [ "unique after relation", - (user: ReturnType) => + (user: ReturnType) => db .uuid() .relation({ @@ -672,11 +765,11 @@ describe("parseTypes", () => { ] as const)( "should throw error when unique is set on n-1 relation (%s)", (_label, buildUserId) => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - const employee = db.type("Employee", { + const employee = db.table("Employee", { userID: buildUserId(user), }); @@ -690,7 +783,7 @@ describe("parseTypes", () => { ); test("should handle self-referencing relations", () => { - const node = db.type("Node", { + const node = db.table("Node", { name: db.string(), parentId: db.uuid().relation({ type: "n-1", @@ -707,11 +800,11 @@ describe("parseTypes", () => { }); test("should not create forward/backward relationships for keyOnly relations", () => { - const user = db.type("User", { + const user = db.table("User", { name: db.string(), }); - const post = db.type("Post", { + const post = db.table("Post", { userId: db.uuid().relation({ type: "keyOnly", toward: { type: user }, diff --git a/packages/sdk/src/parser/service/tailordb/type-parser.ts b/packages/sdk/src/parser/service/tailordb/type-parser.ts index cd539376b..556e9ba85 100644 --- a/packages/sdk/src/parser/service/tailordb/type-parser.ts +++ b/packages/sdk/src/parser/service/tailordb/type-parser.ts @@ -1,6 +1,6 @@ import * as inflection from "inflection"; import { isPluginGeneratedType } from "#/parser/service/tailordb/type-source"; -import { parseFieldConfig } from "./field"; +import { convertTypeHookToExpr, convertTypeValidateToExpr, parseFieldConfig } from "./field"; import { parsePermissions } from "./permission"; import { validateRelationConfig, @@ -171,6 +171,19 @@ function parseTailorDBType( permissions: parsePermissions(metadata.permissions), indexes: metadata.indexes, files: metadata.files, + ...(metadata.typeHook && { + typeHookExpr: { + ...(typeof metadata.typeHook.create === "function" && { + create: convertTypeHookToExpr(metadata.typeHook.create), + }), + ...(typeof metadata.typeHook.update === "function" && { + update: convertTypeHookToExpr(metadata.typeHook.update), + }), + }, + }), + ...(typeof metadata.typeValidate === "function" && { + typeValidateExpr: convertTypeValidateToExpr(metadata.typeValidate), + }), }; } @@ -352,7 +365,7 @@ function validatePluralFormUniqueness( const location = formatTypeSourceLocation(sourceInfo); errors.push( `Type "${parsedType.name}"${location} has identical singular and plural query names "${singularQuery}". ` + - `Use db.type(["${parsedType.name}", "UniquePluralForm"], {...}) to set a unique pluralForm.`, + `Use db.table(["${parsedType.name}", "UniquePluralForm"], {...}) to set a unique pluralForm.`, ); } } diff --git a/packages/sdk/src/parser/service/tailordb/type-script.test.ts b/packages/sdk/src/parser/service/tailordb/type-script.test.ts new file mode 100644 index 000000000..5f579115e --- /dev/null +++ b/packages/sdk/src/parser/service/tailordb/type-script.test.ts @@ -0,0 +1,519 @@ +import { describe, expect, test } from "vitest"; +import { + buildTypeScripts, + computeSourceScriptHash, + extractSourceScriptHash, + type ScriptFieldConfig, +} from "./type-script"; + +describe("buildTypeScripts", () => { + test("returns empty result when no field has hooks or validators", () => { + const fields: Record = { + id: { type: "uuid" }, + name: { type: "string" }, + }; + expect(buildTypeScripts(fields)).toEqual({}); + }); + + test("aggregates field hooks into one script binding a shared timestamp", () => { + const fields: Record = { + createdAt: { + type: "datetime", + hooks: { create: { expr: "_now" } }, + }, + updatedAt: { + type: "datetime", + hooks: { create: { expr: "_now" }, update: { expr: "_now" } }, + }, + }; + + const { typeHook, typeValidate } = buildTypeScripts(fields); + expect(typeValidate).toBeUndefined(); + + // A single `new Date()` is bound once and dispatched to every field. + const createExpr = typeHook?.create?.expr ?? ""; + expect(createExpr.match(/new Date\(\)/g)).toHaveLength(1); + expect(createExpr).toContain('"createdAt": ((_value) => (_now))(_input["createdAt"])'); + expect(createExpr).toContain('"updatedAt": ((_value) => (_now))(_input["updatedAt"])'); + + // createdAt has no update hook, so the update script only touches updatedAt. + const updateExpr = typeHook?.update?.expr ?? ""; + expect(updateExpr).toContain('"updatedAt":'); + expect(updateExpr).not.toContain('"createdAt":'); + }); + + test("reconstructs nested objects so unhooked siblings are preserved", () => { + const fields: Record = { + profile: { + type: "nested", + fields: { + displayName: { type: "string", hooks: { create: { expr: "_value.trim()" } } }, + contact: { + type: "nested", + fields: { + email: { type: "string", hooks: { create: { expr: "_value.toLowerCase()" } } }, + }, + }, + }, + }, + }; + + const createExpr = buildTypeScripts(fields).typeHook?.create?.expr ?? ""; + expect(createExpr).toContain('"profile": Object.assign({}, _input["profile"], {'); + expect(createExpr).toContain( + '"displayName": ((_value) => (_value.trim()))((_input["profile"] || {})["displayName"])', + ); + expect(createExpr).toContain( + '"contact": Object.assign({}, (_input["profile"] || {})["contact"], {', + ); + expect(createExpr).toContain( + '"email": ((_value) => (_value.toLowerCase()))(((_input["profile"] || {})["contact"] || {})["email"])', + ); + }); + + test("applies default as ?? fallback after hook on create only", () => { + const fields: Record = { + status: { + type: "enum", + default: "active", + hooks: { create: { expr: "_value" } }, + }, + name: { + type: "string", + default: "unnamed", + }, + }; + + const { typeHook } = buildTypeScripts(fields); + const createExpr = typeHook?.create?.expr ?? ""; + + // hook + default: hookResult ?? defaultValue + expect(createExpr).toContain('"status": ((_value) => (_value))(_input["status"]) ?? "active"'); + // default only: input ?? defaultValue + expect(createExpr).toContain('"name": _input["name"] ?? "unnamed"'); + + // defaults are create-only — update script should not include them + expect(typeHook?.update).toBeUndefined(); + }); + + test("uses _now for datetime/date/time defaults with 'now'", () => { + const fields: Record = { + createdAt: { type: "datetime", default: "now" }, + startDate: { type: "date", default: "now" }, + startTime: { type: "time", default: "now" }, + label: { type: "string", default: "now" }, + }; + + const createExpr = buildTypeScripts(fields).typeHook?.create?.expr ?? ""; + expect(createExpr).toContain('"createdAt": _input["createdAt"] ?? _now'); + expect(createExpr).toContain('"startDate": _input["startDate"] ?? _now'); + expect(createExpr).toContain('"startTime": _input["startTime"] ?? _now'); + // "now" on a string field is just a literal string, not _now + expect(createExpr).toContain('"label": _input["label"] ?? "now"'); + }); + + test("builds a validate script that runs all validators and collects all errors", () => { + const fields: Record = { + age: { + type: "integer", + validate: [ + { script: { expr: "_value >= 0" }, errorMessage: "" }, + { script: { expr: "_value < 200" }, errorMessage: "" }, + ], + }, + }; + + const { typeValidate, typeHook } = buildTypeScripts(fields); + expect(typeHook).toBeUndefined(); + + const createExpr = typeValidate?.create?.expr ?? ""; + expect(typeValidate?.update?.expr).toBe(createExpr); + expect(createExpr).toContain("const __errs = {}"); + expect(createExpr).toContain('const _value = _newRecord["age"]'); + expect(createExpr).not.toContain("_oldValue"); + expect(createExpr).toContain("(_value >= 0)"); + expect(createExpr).toContain("(_value < 200)"); + expect(createExpr).toContain('if (typeof __r === "string") { __errs["age"] = __r; }'); + expect(createExpr).toContain("return __errs"); + expect(createExpr).not.toContain("new Date()"); + }); + + test("includes type-level validate with __issues function", () => { + const typeValidateExpr = + '(({ newRecord }) => { if (newRecord.start > newRecord.end) __issues("start", "bad"); })({ newRecord: _newRecord, oldRecord: _oldRecord }, __issues)'; + + const { typeValidate } = buildTypeScripts({}, { typeValidateExpr }); + const expr = typeValidate?.create?.expr ?? ""; + expect(typeValidate?.update?.expr).toBe(expr); + expect(expr).toContain("const __errs = {}"); + expect(expr).toContain("const __issues = (f, m) => { __errs[f] = m; }"); + expect(expr).toContain(typeValidateExpr); + expect(expr).toContain("return __errs"); + }); + + test("combines field validators and type-level validate in one script", () => { + const fields: Record = { + name: { + type: "string", + validate: [{ script: { expr: "checkName(_value)" }, errorMessage: "" }], + }, + }; + const typeValidateExpr = "typeValidateFn({ newRecord: _newRecord }, __issues)"; + + const { typeValidate } = buildTypeScripts(fields, { typeValidateExpr }); + const expr = typeValidate?.create?.expr ?? ""; + expect(expr).toContain('const _value = _newRecord["name"]'); + expect(expr).toContain("const __issues = (f, m) => { __errs[f] = m; }"); + expect(expr).toContain(typeValidateExpr); + }); + + test("throws on defaults on nested array inner fields", () => { + const fields: Record = { + items: { + type: "nested", + array: true, + fields: { + status: { type: "string", default: "pending" }, + }, + }, + }; + + expect(() => buildTypeScripts(fields)).toThrow( + '.default() cannot be used on nested inner field "status"', + ); + }); + + test("validates per element in nested array fields with indexed error paths", () => { + const fields: Record = { + items: { + type: "nested", + array: true, + fields: { + name: { + type: "string", + validate: [ + { script: { expr: '_value.length > 0 ? undefined : "required"' }, errorMessage: "" }, + ], + }, + }, + }, + }; + + const expr = buildTypeScripts(fields).typeValidate?.create?.expr ?? ""; + expect(expr).toContain('(_newRecord["items"] || []).forEach((__el, __idx) => {'); + expect(expr).toContain('const _value = __el["name"]'); + expect(expr).toContain('"items[" + __idx + "].name"'); + }); + + test("nested array forEach terminates with semicolon to prevent ASI with type-level validate", () => { + const fields: Record = { + items: { + type: "nested", + array: true, + fields: { + qty: { + type: "integer", + validate: [{ script: { expr: "_value > 0" }, errorMessage: "" }], + }, + }, + }, + }; + const typeValidateExpr = "fn({ newRecord: _newRecord }, __issues)"; + + const { typeValidate } = buildTypeScripts(fields, { typeValidateExpr }); + const expr = typeValidate?.create?.expr ?? ""; + expect(expr).toContain("});"); + + const _newRecord = { items: [{ qty: 1 }] }; // eslint-disable-line + const fn = () => {}; // eslint-disable-line + expect(() => new Function("_newRecord", "fn", `return ${expr}`)(_newRecord, fn)).not.toThrow(); + }); + + test("skips nested array with no defaults or validators", () => { + const fields: Record = { + items: { + type: "nested", + array: true, + fields: { + name: { type: "string" }, + }, + }, + }; + expect(buildTypeScripts(fields)).toEqual({}); + }); + + test("skips nested object child validators when parent is absent", () => { + const fields: Record = { + address: { + type: "nested", + fields: { + city: { + type: "string", + validate: [ + { script: { expr: '_value.length > 0 ? undefined : "required"' }, errorMessage: "" }, + ], + }, + }, + }, + }; + + const expr = buildTypeScripts(fields).typeValidate?.create?.expr ?? ""; + expect(expr).toContain('if (_newRecord["address"] != null)'); + + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const run = (record: unknown) => new Function("_newRecord", `return ${expr}`)(record); + expect(run({ address: null })).toEqual({}); + expect(run({ address: { city: "" } })).toEqual({ "address.city": "required" }); + }); + + test("no typeValidate output when no field validators and no type-level validate", () => { + expect(buildTypeScripts({})).toEqual({}); + expect(buildTypeScripts({}, undefined)).toEqual({}); + }); + + test("captures _invoker safely so scripts work when Platform does not inject it", () => { + const fields: Record = { + createdAt: { + type: "datetime", + hooks: { create: { expr: "_now" } }, + }, + }; + const typeHookExpr = { + create: "(({ input }) => ({ computed: input.a }))({ input: _input, invoker: _invoker })", + }; + + const { typeHook } = buildTypeScripts(fields, { typeHookExpr }); + + const hookExpr = typeHook?.create?.expr ?? ""; + expect(hookExpr).toMatch(/^\(\(_invoker\) =>/); + expect(hookExpr).toContain('typeof _invoker !== "undefined" ? _invoker : undefined'); + + const validateExpr = + buildTypeScripts( + { x: { type: "string", validate: [{ script: { expr: "true" }, errorMessage: "" }] } }, + { typeValidateExpr: "fn(_invoker)" }, + ).typeValidate?.create?.expr ?? ""; + expect(validateExpr).toMatch(/^\(\(_invoker\) =>/); + expect(validateExpr).toContain('typeof _invoker !== "undefined" ? _invoker : undefined'); + }); + + test("type-level hook receives field-level results as input, not raw _input", () => { + const fields: Record = { + status: { type: "string", default: "active" }, + }; + const typeHookExpr = { + create: + "(({ input }) => ({ label: input.status }))({ input: _input, oldRecord: _oldRecord, invoker: _invoker, now: _now })", + }; + + const { typeHook } = buildTypeScripts(fields, { typeHookExpr }); + const expr = typeHook?.create?.expr ?? ""; + + // Field-level result is bound to __fl + expect(expr).toContain("const __fl ="); + expect(expr).toContain('"status": _input["status"] ?? "active"'); + + // Type-level hook receives field-level result via IIFE that shadows _input + expect(expr).toContain("((_input) =>"); + expect(expr).toContain("Object.assign({}, _input, __fl)"); + + // Final result merges field-level and type-level + expect(expr).toContain("Object.assign({}, __fl,"); + }); + + test("field-level + type-level hook evaluates correctly at runtime", () => { + const fields: Record = { + name: { type: "string", hooks: { create: { expr: "_value.trim()" } } }, + }; + const typeHookExpr = { + create: + "(({ input }) => ({ upper: input.name.toUpperCase() }))({ input: _input, oldRecord: _oldRecord, invoker: _invoker, now: _now })", + }; + + const { typeHook } = buildTypeScripts(fields, { typeHookExpr }); + const expr = typeHook?.create?.expr ?? ""; + + // Evaluate the generated expression with a mock _input + const _input = { name: " hello " }; // eslint-disable-line + const _oldRecord = null; // eslint-disable-line + const result = new Function("_input", "_oldRecord", `return ${expr}`)(_input, _oldRecord); + // Field-level hook trims name + expect(result.name).toBe("hello"); + // Type-level hook sees trimmed input and uppercases it + expect(result.upper).toBe("HELLO"); + }); + + test("update type-level hook falls back to oldRecord via _oldRecord", () => { + const fields: Record = { + createdAt: { + type: "datetime", + hooks: { create: { expr: "_now" } }, + }, + updatedAt: { + type: "datetime", + hooks: { create: { expr: "_now" }, update: { expr: "_now" } }, + }, + }; + const typeHookExpr = { + create: + "(({ input }) => ({ label: `${input.name} Bundle` }))({ input: _input, oldRecord: _oldRecord, invoker: _invoker, now: _now })", + update: + "(({ input, oldRecord }) => ({ label: `${input.name ?? oldRecord.name} Bundle` }))({ input: _input, oldRecord: _oldRecord, invoker: _invoker, now: _now })", + }; + + const { typeHook } = buildTypeScripts(fields, { typeHookExpr }); + + // UPDATE: input has no name, oldRecord has name + const updateExpr = typeHook?.update?.expr ?? ""; + const _input = { items: [{ productName: "Item", qty: 3, unitPrice: 5.0 }] }; // eslint-disable-line + const _oldRecord = { + // eslint-disable-line + id: "abc", + name: "Stable", + label: "Stable Bundle", + items: [{ productName: "Item", qty: 1, unitPrice: 5.0 }], + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + }; + const result = new Function("_input", "_oldRecord", `return ${updateExpr}`)(_input, _oldRecord); + expect(result.label).toBe("Stable Bundle"); + }); + + test("nested array hooks do not reference __oldEl", () => { + const fields: Record = { + items: { + type: "nested", + array: true, + fields: { + qty: { + type: "integer", + hooks: { update: { expr: "_value ?? _oldValue" } }, + }, + }, + }, + }; + + const updateExpr = buildTypeScripts(fields).typeHook?.update?.expr ?? ""; + expect(updateExpr).not.toContain("__oldEl"); + }); +}); + +describe("computeSourceScriptHash", () => { + test("returns undefined when no script-relevant data exists", () => { + expect(computeSourceScriptHash({ id: { type: "uuid" } })).toBeUndefined(); + expect(computeSourceScriptHash({})).toBeUndefined(); + }); + + test("returns a 16-char hex string when scripts exist", () => { + const hash = computeSourceScriptHash({ + name: { type: "string", hooks: { create: { expr: "_value.trim()" } } }, + }); + expect(hash).toMatch(/^[0-9a-f]{16}$/); + }); + + test("is deterministic for the same input", () => { + const fields: Record = { + status: { type: "string", default: "active" }, + name: { type: "string", hooks: { create: { expr: "_value.trim()" } } }, + }; + expect(computeSourceScriptHash(fields)).toBe(computeSourceScriptHash(fields)); + }); + + test("changes when a hook expression changes", () => { + const a = computeSourceScriptHash({ + name: { type: "string", hooks: { create: { expr: "_value.trim()" } } }, + }); + const b = computeSourceScriptHash({ + name: { type: "string", hooks: { create: { expr: "_value.toLowerCase()" } } }, + }); + expect(a).not.toBe(b); + }); + + test("changes when typeHookExpr changes", () => { + const fields: Record = {}; + const a = computeSourceScriptHash(fields, { typeHookExpr: { create: "exprA" } }); + const b = computeSourceScriptHash(fields, { typeHookExpr: { create: "exprB" } }); + expect(a).not.toBe(b); + }); + + test("changes when typeValidateExpr changes", () => { + const a = computeSourceScriptHash({}, { typeValidateExpr: "validateA" }); + const b = computeSourceScriptHash({}, { typeValidateExpr: "validateB" }); + expect(a).not.toBe(b); + }); + + test("is insensitive to field insertion order", () => { + const a = computeSourceScriptHash({ + alpha: { type: "string", hooks: { create: { expr: "a" } } }, + beta: { type: "string", hooks: { create: { expr: "b" } } }, + }); + const b = computeSourceScriptHash({ + beta: { type: "string", hooks: { create: { expr: "b" } } }, + alpha: { type: "string", hooks: { create: { expr: "a" } } }, + }); + expect(a).toBe(b); + }); + + test("includes nested field scripts in hash", () => { + const withNested = computeSourceScriptHash({ + profile: { + type: "nested", + fields: { + email: { type: "string", hooks: { create: { expr: "_value.toLowerCase()" } } }, + }, + }, + }); + const withoutNested = computeSourceScriptHash({ + profile: { type: "nested", fields: { email: { type: "string" } } }, + }); + expect(withNested).toBeDefined(); + expect(withoutNested).toBeUndefined(); + }); +}); + +describe("extractSourceScriptHash", () => { + test("extracts hash from expr with hash suffix", () => { + const expr = "((_invoker) => { return {}; })() // @sdk-source-hash:abcdef0123456789"; + expect(extractSourceScriptHash(expr)).toBe("abcdef0123456789"); + }); + + test("returns undefined when no hash is present", () => { + expect(extractSourceScriptHash("((_invoker) => { return {}; })()")).toBeUndefined(); + }); +}); + +describe("buildTypeScripts hash embedding", () => { + test("embeds hash in generated hook expr", () => { + const fields: Record = { + createdAt: { type: "datetime", hooks: { create: { expr: "_now" } } }, + }; + const { typeHook } = buildTypeScripts(fields); + const expr = typeHook?.create?.expr ?? ""; + const hash = extractSourceScriptHash(expr); + expect(hash).toMatch(/^[0-9a-f]{16}$/); + expect(hash).toBe(computeSourceScriptHash(fields)); + }); + + test("embeds same hash in all generated exprs", () => { + const fields: Record = { + name: { + type: "string", + hooks: { create: { expr: "_value.trim()" }, update: { expr: "_value.trim()" } }, + validate: [{ script: { expr: "_value.length > 0" }, errorMessage: "required" }], + }, + }; + const { typeHook, typeValidate } = buildTypeScripts(fields); + const hashes = [ + extractSourceScriptHash(typeHook?.create?.expr ?? ""), + extractSourceScriptHash(typeHook?.update?.expr ?? ""), + extractSourceScriptHash(typeValidate?.create?.expr ?? ""), + extractSourceScriptHash(typeValidate?.update?.expr ?? ""), + ]; + expect(new Set(hashes).size).toBe(1); + expect(hashes[0]).toMatch(/^[0-9a-f]{16}$/); + }); + + test("no hash in empty result", () => { + expect(buildTypeScripts({ id: { type: "uuid" } })).toEqual({}); + }); +}); diff --git a/packages/sdk/src/parser/service/tailordb/type-script.ts b/packages/sdk/src/parser/service/tailordb/type-script.ts new file mode 100644 index 000000000..9a5ce61bd --- /dev/null +++ b/packages/sdk/src/parser/service/tailordb/type-script.ts @@ -0,0 +1,314 @@ +import { createHash } from "node:crypto"; + +// Platform-injected record map for type-level hook/validate scripts. +const INPUT = "_input"; +const NEW_RECORD = "_newRecord"; +const OLD_RECORD = "_oldRecord"; +// Shared operation timestamp bound once per script execution. +const NOW = "_now"; + +const SOURCE_HASH_PREFIX = "// @sdk-source-hash:"; + +const TIME_TYPES = new Set(["datetime", "date", "time"]); + +type HookOperation = "create" | "update"; + +interface ScriptRef { + expr: string; +} + +/** + * Minimal structural shape shared by parser `OperatorFieldConfig` and migration + * `SnapshotFieldConfig`. Only the parts needed to aggregate type-level scripts. + */ +export interface ScriptFieldConfig { + type: string; + array?: boolean; + hooks?: { + create?: ScriptRef; + update?: ScriptRef; + }; + validate?: { script?: ScriptRef; errorMessage: string }[]; + default?: unknown; + fields?: Record; +} + +export interface TypeScripts { + typeHook?: { create?: ScriptRef; update?: ScriptRef }; + typeValidate?: { create?: ScriptRef; update?: ScriptRef }; +} + +const key = (name: string) => JSON.stringify(name); + +function collectFieldScriptSources(fields: Record): [string, unknown][] { + const entries: [string, unknown][] = []; + + for (const [name, config] of Object.entries(fields).toSorted(([a], [b]) => a.localeCompare(b))) { + const data: Record = {}; + + if (config.hooks?.create?.expr) data.hc = config.hooks.create.expr; + if (config.hooks?.update?.expr) data.hu = config.hooks.update.expr; + if (config.validate?.some((v) => v.script?.expr)) { + data.v = config.validate + .filter((v) => v.script?.expr) + .map((v) => [v.script?.expr, v.errorMessage]); + } + if (config.default !== undefined) { + data.d = config.default instanceof Date ? config.default.toISOString() : config.default; + data.t = config.type; + } + + if (config.fields) { + const nested = collectFieldScriptSources(config.fields); + if (nested.length > 0) { + data.f = nested; + data.a = !!config.array; + } + } + + if (Object.keys(data).length > 0) { + entries.push([name, data]); + } + } + + return entries; +} + +export function computeSourceScriptHash( + fields: Record, + options?: { + typeHookExpr?: { create?: string; update?: string }; + typeValidateExpr?: string; + }, +): string | undefined { + const fieldSources = collectFieldScriptSources(fields); + const hasTypeScripts = + fieldSources.length > 0 || + options?.typeHookExpr?.create || + options?.typeHookExpr?.update || + options?.typeValidateExpr; + + if (!hasTypeScripts) return undefined; + + const payload: unknown[] = [fieldSources]; + if (options?.typeHookExpr?.create) payload.push(["thc", options.typeHookExpr.create]); + if (options?.typeHookExpr?.update) payload.push(["thu", options.typeHookExpr.update]); + if (options?.typeValidateExpr) payload.push(["tve", options.typeValidateExpr]); + + return createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 16); +} + +export function extractSourceScriptHash(expr: string): string | undefined { + const match = expr.match(/\/\/ @sdk-source-hash:([0-9a-f]+)\s*$/); + return match?.[1]; +} + +const isNestedType = (config: ScriptFieldConfig): boolean => + config.type === "nested" && config.fields !== undefined; + +function serializeDefault(value: unknown, fieldType: string): string { + if (value === "now" && TIME_TYPES.has(fieldType)) return NOW; + if (value instanceof Date) return `new Date(${JSON.stringify(value.toISOString())})`; + return JSON.stringify(value); +} + +/** + * Build the object literal that reconstructs one record level with hook + * overrides and defaults applied. For create, defaults are appended as + * `?? defaultValue` after the hook expression (field hook → default). + * Returns null when no field under this level has a hook or default for + * the operation. + * @param {Record} fields - Field configurations + * @param {string} accessExpr - JS expression to access the parent object + * @param {string} oldAccessExpr - JS expression to access the old record parent + * @param {HookOperation} operation - Hook operation type + * @param {boolean} nested - Whether building inside a nested field (rejects defaults) + * @returns {string | null} Object literal expression or null + */ +function buildHookObject( + fields: Record, + accessExpr: string, + oldAccessExpr: string, + operation: HookOperation, + nested = false, +): string | null { + const parts: string[] = []; + + for (const [name, config] of Object.entries(fields)) { + const access = `${accessExpr}[${key(name)}]`; + const oldAccess = `${oldAccessExpr}?.[${key(name)}]`; + if (isNestedType(config) && config.fields) { + if (config.array) { + const inner = buildHookObject(config.fields, "__el", "undefined", operation, true); + if (inner !== null) { + parts.push( + `${key(name)}: (${access} || []).map((__el) => Object.assign({}, __el, ${inner}))`, + ); + } + } else { + const inner = buildHookObject( + config.fields, + `(${access} || {})`, + oldAccess, + operation, + true, + ); + if (inner !== null) { + parts.push(`${key(name)}: Object.assign({}, ${access}, ${inner})`); + } + } + continue; + } + + const hook = config.hooks?.[operation]; + if (nested && config.default !== undefined) { + throw new Error(`.default() cannot be used on nested inner field "${name}"`); + } + const hasDefault = operation === "create" && config.default !== undefined; + + if (hook && hasDefault) { + parts.push( + `${key(name)}: ((_value) => (${hook.expr}))(${access}) ?? ${serializeDefault(config.default, config.type)}`, + ); + } else if (hook && operation === "create") { + parts.push(`${key(name)}: ((_value) => (${hook.expr}))(${access})`); + } else if (hook) { + parts.push( + `${key(name)}: ((_value, _oldValue) => (${hook.expr}))(${access}, ${oldAccess} ?? null)`, + ); + } else if (hasDefault) { + parts.push(`${key(name)}: ${access} ?? ${serializeDefault(config.default, config.type)}`); + } + } + + if (parts.length === 0) return null; + return `{ ${parts.join(", ")} }`; +} + +/** + * Build validation statements for one record level. + * Each leaf field with validators contributes a block that runs every + * validator and records all failing messages keyed by dotted field path. + * @param {Record} fields - Field configurations + * @param {string} accessExpr - JS expression to access the parent object + * @param {string} keyPrefix - Dotted path prefix for error keys + * @returns {string[]} Array of validation statement strings + */ +function buildValidateStatements( + fields: Record, + accessExpr: string, + keyPrefix: string, +): string[] { + const statements: string[] = []; + + for (const [name, config] of Object.entries(fields)) { + const access = `${accessExpr}[${key(name)}]`; + const fieldPath = keyPrefix ? `${keyPrefix}.${name}` : name; + + const validators = (config.validate ?? []).filter((v) => v.script?.expr); + if (validators.length > 0) { + const checks = validators + .map( + (v) => + `{ const __r = (${v.script?.expr}); if (typeof __r === "string") { __errs[${key(fieldPath)}] = __r; } }`, + ) + .join("\n"); + statements.push(`{ const _value = ${access};\n${checks}\n}`); + } + + if (isNestedType(config) && config.fields) { + if (config.array) { + const innerParts: string[] = []; + for (const [innerName, innerConfig] of Object.entries(config.fields)) { + const innerValidators = (innerConfig.validate ?? []).filter((v) => v.script?.expr); + if (innerValidators.length > 0) { + const errorKeyExpr = `${JSON.stringify(fieldPath + "[")} + __idx + ${JSON.stringify("]." + innerName)}`; + const checks = innerValidators + .map( + (v) => + `{ const __r = (${v.script?.expr}); if (typeof __r === "string") { __errs[${errorKeyExpr}] = __r; } }`, + ) + .join("\n"); + innerParts.push(`{ const _value = __el[${key(innerName)}];\n${checks}\n}`); + } + } + if (innerParts.length > 0) { + statements.push( + `(${access} || []).forEach((__el, __idx) => {\n${innerParts.join("\n")}\n});`, + ); + } + } else { + const nested = buildValidateStatements(config.fields, access, fieldPath); + if (nested.length > 0) { + statements.push(`if (${access} != null) {\n${nested.join("\n")}\n}`); + } + } + } + } + + return statements; +} + +function wrapHook(objectExpr: string): string { + return `((_invoker) => { const ${NOW} = new Date(); return ${objectExpr}; })(typeof _invoker !== "undefined" ? _invoker : undefined)`; +} + +function wrapValidate(statements: string[], typeValidateExpr?: string): string { + const issuesFn = typeValidateExpr ? " const __issues = (f, m) => { __errs[f] = m; };" : ""; + const typeValidateStmt = typeValidateExpr ? ` ${typeValidateExpr};` : ""; + return `((_invoker) => { const __errs = {};${issuesFn}\n${statements.join("\n")}${typeValidateStmt}\nreturn __errs; })(typeof _invoker !== "undefined" ? _invoker : undefined)`; +} + +/** + * Aggregate every field's create/update hook, default, and validate into + * type-level scripts. Hooks compute a single shared timestamp (`now`) per + * operation, so all fields touched in one create/update observe the same + * instant. Defaults are applied after hooks on create only. Validators + * run with the same rules on create and update. + * @param fields - Per-field script configuration + * @param options - Optional type-level hook/validate expressions + * @returns Aggregated type-level scripts + */ +export function buildTypeScripts( + fields: Record, + options?: { + typeHookExpr?: { create?: string; update?: string }; + typeValidateExpr?: string; + }, +): TypeScripts { + const result: TypeScripts = {}; + const typeHookExpr = options?.typeHookExpr; + const typeValidateExpr = options?.typeValidateExpr; + + const hash = computeSourceScriptHash(fields, options); + const hashSuffix = hash ? ` ${SOURCE_HASH_PREFIX}${hash}` : ""; + + const hook: { create?: ScriptRef; update?: ScriptRef } = {}; + for (const operation of ["create", "update"] as const) { + const perFieldExpr = buildHookObject(fields, INPUT, OLD_RECORD, operation); + const typeLevelExpr = typeHookExpr?.[operation]; + let expr: string | undefined; + if (perFieldExpr !== null && typeLevelExpr) { + expr = `((_invoker) => { const ${NOW} = new Date(); const __fl = ${perFieldExpr}; return Object.assign({}, __fl, ((${INPUT}) => ${typeLevelExpr})(Object.assign({}, ${INPUT}, __fl))); })(typeof _invoker !== "undefined" ? _invoker : undefined)`; + } else if (typeLevelExpr) { + expr = wrapHook(typeLevelExpr); + } else if (perFieldExpr !== null) { + expr = wrapHook(perFieldExpr); + } + + if (expr) { + hook[operation] = { expr: expr + hashSuffix }; + } + } + if (hook.create || hook.update) { + result.typeHook = hook; + } + + const statements = buildValidateStatements(fields, NEW_RECORD, ""); + if (statements.length > 0 || typeValidateExpr) { + const expr = wrapValidate(statements, typeValidateExpr) + hashSuffix; + result.typeValidate = { create: { expr }, update: { expr } }; + } + + return result; +} diff --git a/packages/sdk/src/parser/service/tailordb/types.ts b/packages/sdk/src/parser/service/tailordb/types.ts index c478ca7e0..a3dd0a75b 100644 --- a/packages/sdk/src/parser/service/tailordb/types.ts +++ b/packages/sdk/src/parser/service/tailordb/types.ts @@ -79,6 +79,7 @@ export interface OperatorFieldConfig { format?: string; }; scale?: number; + default?: unknown; fields?: Record; } @@ -171,4 +172,6 @@ export interface TailorDBType { permissions: Permissions; indexes?: TailorDBTypeMetadata["indexes"]; files?: TailorDBTypeMetadata["files"]; + typeHookExpr?: { create?: string; update?: string }; + typeValidateExpr?: string; } diff --git a/packages/sdk/src/parser/service/workflow/schema.ts b/packages/sdk/src/parser/service/workflow/schema.ts index 9b45a628b..1b643adce 100644 --- a/packages/sdk/src/parser/service/workflow/schema.ts +++ b/packages/sdk/src/parser/service/workflow/schema.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import { functionSchema } from "../common"; -export const WorkflowJobSchema = z.object({ +export const WorkflowJobSchema = z.strictObject({ name: z.string().describe("Job name (must be unique across the project)"), - trigger: functionSchema.describe("Trigger function that initiates the job"), + start: functionSchema.describe("Start function that initiates the job"), body: functionSchema.describe("Job implementation function"), }); @@ -32,7 +32,7 @@ const durationSchema = (maxSeconds: number) => }); export const RetryPolicySchema = z - .object({ + .strictObject({ maxRetries: z.number().int().min(1).max(10).describe("Maximum number of retries (1-10)"), initialBackoff: durationSchema(3600).describe( "Initial backoff duration (e.g., '1s', '500ms', '1m', max 1h)", @@ -42,6 +42,7 @@ export const RetryPolicySchema = z ), backoffMultiplier: z.number().min(1).describe("Backoff multiplier (>= 1)"), }) + .refine((data) => durationToSeconds(data.initialBackoff) <= durationToSeconds(data.maxBackoff), { message: "initialBackoff must be less than or equal to maxBackoff", path: ["initialBackoff"], @@ -51,7 +52,7 @@ export const RetryPolicySchema = z path: ["initialBackoff"], }); -export const ConcurrencyPolicySchema = z.object({ +export const ConcurrencyPolicySchema = z.strictObject({ maxConcurrentExecutions: z .number() .int() @@ -74,11 +75,9 @@ export const ExecutionPolicyKeySchema = z /^[a-z0-9][a-z0-9_:.-]{0,62}[a-z0-9*]$/, "Invalid execution policy key: must match [a-z0-9_:.-] (2-64 chars; must start with [a-z0-9] and end with [a-z0-9] or a trailing '*')", ) - .describe( - "Execution policy key passed to startJobFunction's (or its frozen alias triggerJobFunction's) executionPolicyKey option", - ); + .describe("Execution policy key passed to startJobFunction's executionPolicyKey option"); -export const WorkflowJobFunctionExecutionPolicySchema = z.object({ +export const WorkflowJobFunctionExecutionPolicySchema = z.strictObject({ name: ExecutionPolicyNameSchema, key: ExecutionPolicyKeySchema, concurrencyPolicy: ConcurrencyPolicySchema.optional().describe( @@ -86,7 +85,7 @@ export const WorkflowJobFunctionExecutionPolicySchema = z.object({ ), }); -export const WorkflowSchema = z.object({ +export const WorkflowSchema = z.strictObject({ name: z.string().describe("Workflow name"), mainJob: WorkflowJobSchema.describe("Main job that starts the workflow"), retryPolicy: RetryPolicySchema.optional().describe("Retry policy for the workflow"), diff --git a/packages/sdk/src/plugin/builtin/enum-constants/index.test.ts b/packages/sdk/src/plugin/builtin/enum-constants/index.test.ts index 76c52dc6b..5247a7c42 100644 --- a/packages/sdk/src/plugin/builtin/enum-constants/index.test.ts +++ b/packages/sdk/src/plugin/builtin/enum-constants/index.test.ts @@ -27,7 +27,7 @@ describe("EnumConstantsPlugin", () => { describe("enum collection", () => { test("should collect top-level enum fields", async () => { - const type = db.type("User", { + const type = db.table("User", { role: db.enum(["ADMIN", "USER"]), status: db.enum(["ACTIVE", "INACTIVE"], { optional: true }), }); @@ -48,7 +48,7 @@ describe("EnumConstantsPlugin", () => { }); test("should collect enum fields from nested objects", async () => { - const type = db.type("PurchaseOrder", { + const type = db.table("PurchaseOrder", { attachedFiles: db.object( { id: db.uuid(), @@ -70,7 +70,7 @@ describe("EnumConstantsPlugin", () => { }); test("should return empty array when no enums are present", async () => { - const type = db.type("User", { + const type = db.table("User", { name: db.string(), age: db.int(), }); @@ -81,7 +81,7 @@ describe("EnumConstantsPlugin", () => { }); test("should collect enum values with descriptions", async () => { - const type = db.type("Invoice", { + const type = db.table("Invoice", { status: db.enum([ { value: "draft", description: "Draft invoice" }, { value: "sent", description: "Sent invoice" }, diff --git a/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts b/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts index 4c7238332..64fc6b0ab 100644 --- a/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts +++ b/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts @@ -38,12 +38,12 @@ export function generateUnifiedFileUtils( const importStatement = multiline /* ts */ ` - import * as file from "@tailor-platform/sdk/runtime/file"; + import { file } from "@tailor-platform/sdk/runtime/file"; import type { FileUploadOptions, FileUploadResponse, FileMetadata, - FileStreamIterator, + FileDownloadStreamResponse, } from "@tailor-platform/sdk/runtime/file"; ` + "\n"; @@ -116,15 +116,15 @@ export function generateUnifiedFileUtils( } ` + "\n"; - // Generate openFileDownloadStream helper function - const openDownloadStreamFunction = + // Generate downloadFileStream helper function + const downloadStreamFunction = multiline /* ts */ ` - export async function openFileDownloadStream( + export async function downloadFileStream( type: T, field: TypeWithFiles[T]["fields"], recordId: string, - ): Promise { - return await file.openDownloadStream(namespaces[type], type, field, recordId); + ): Promise { + return await file.downloadStream(namespaces[type], type, field, recordId); } ` + "\n"; @@ -136,6 +136,6 @@ export function generateUnifiedFileUtils( uploadFunction, deleteFunction, getMetadataFunction, - openDownloadStreamFunction, + downloadStreamFunction, ].join("\n"); } diff --git a/packages/sdk/src/plugin/builtin/file-utils/index.test.ts b/packages/sdk/src/plugin/builtin/file-utils/index.test.ts index b7c40d508..8cee1e06b 100644 --- a/packages/sdk/src/plugin/builtin/file-utils/index.test.ts +++ b/packages/sdk/src/plugin/builtin/file-utils/index.test.ts @@ -55,7 +55,7 @@ describe("FileUtilsPlugin", () => { ], ["should return empty array when no files are present", "User", undefined, []], ])("%s", async (_name, typeName, files, expectedFields) => { - let type = db.type(typeName, { name: db.string() }); + let type = db.table(typeName, { name: db.string() }); if (files) type = type.files(files); const result = await processFileType(parseTailorDBType(toSchemaOutput(type))); @@ -85,6 +85,12 @@ describe("FileUtilsPlugin", () => { expect(result).toContain('"receipt" | "form"'); expect(result).toContain('User: "tailordb"'); expect(result).toContain('SalesOrder: "tailordb"'); + expect(result).toContain("FileDownloadStreamResponse"); + expect(result).toContain("export async function downloadFileStream"); + expect(result).toContain("return await file.downloadStream"); + expect(result).not.toContain("FileStreamIterator"); + expect(result).not.toContain("openFileDownloadStream"); + expect(result).not.toContain("openDownloadStream"); }); test("should merge types from multiple namespaces", () => { @@ -139,7 +145,7 @@ describe("FileUtilsPlugin", () => { describe("onTailorDBReady integration", () => { test("should generate file utils for types with file fields", async () => { const userType = db - .type("User", { + .table("User", { name: db.string(), }) .files({ @@ -147,7 +153,7 @@ describe("FileUtilsPlugin", () => { }); const salesOrderType = db - .type("SalesOrder", { + .table("SalesOrder", { name: db.string(), }) .files({ @@ -178,7 +184,7 @@ describe("FileUtilsPlugin", () => { }); test("should return empty files when no types have file fields", async () => { - const userType = db.type("User", { + const userType = db.table("User", { name: db.string(), }); @@ -199,7 +205,7 @@ describe("FileUtilsPlugin", () => { test("should handle multiple namespaces", async () => { const userType = db - .type("User", { + .table("User", { name: db.string(), }) .files({ @@ -207,7 +213,7 @@ describe("FileUtilsPlugin", () => { }); const customerType = db - .type("Customer", { + .table("Customer", { name: db.string(), }) .files({ diff --git a/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts b/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts index 5b857eaf1..82a0f7517 100644 --- a/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts +++ b/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts @@ -13,7 +13,7 @@ function parseTailorDBType(type: TailorDBTypeSchemaOutput): TailorDBType { return types[type.name]!; } -const mockBasicType = db.type("User", { +const mockBasicType = db.table("User", { name: db.string().description("User name"), email: db.string().description("User email"), age: db.int({ optional: true }), @@ -25,12 +25,12 @@ const mockBasicType = db.type("User", { ...db.fields.timestamps(), }); -const mockEnumType = db.type("Status", { +const mockEnumType = db.table("Status", { status: db.enum([{ value: "active" }, { value: "inactive" }, { value: "pending" }]), priority: db.enum([{ value: "high" }, { value: "medium" }, { value: "low" }], { optional: true }), }); -const mockNestedType = db.type("ComplexUser", { +const mockNestedType = db.table("ComplexUser", { profile: db.object({ firstName: db.string(), lastName: db.string(), @@ -88,7 +88,7 @@ describe("KyselyTypePlugin integration tests", () => { expect(result.typeDef).toContain("lastLogin: Timestamp | null;"); expect(result.typeDef).toContain("tags: string[];"); expect(result.typeDef).toContain("createdAt: Generated;"); - expect(result.typeDef).toContain("updatedAt: Timestamp | null;"); + expect(result.typeDef).toContain("updatedAt: Generated;"); }); test("should have correct id and description", () => { @@ -121,7 +121,7 @@ describe("KyselyTypePlugin integration tests", () => { }); test("correctly processes required/optional fields", async () => { - const testType = db.type("TestRequired", { + const testType = db.table("TestRequired", { requiredField: db.string(), optionalField: db.string({ optional: true }), undefinedRequiredField: db.string({ optional: true }), @@ -135,7 +135,7 @@ describe("KyselyTypePlugin integration tests", () => { }); test("correctly processes array types", async () => { - const arrayType = db.type("ArrayTest", { + const arrayType = db.table("ArrayTest", { stringArray: db.string({ array: true }), optionalIntArray: db.int({ optional: true, array: true }), }); @@ -214,7 +214,7 @@ describe("KyselyTypePlugin integration tests", () => { }); test("processes unknown type definitions as string type", async () => { - const unknownType = db.type("UnknownType", { + const unknownType = db.table("UnknownType", { unknownField: db.string(), }); @@ -226,8 +226,8 @@ describe("KyselyTypePlugin integration tests", () => { describe("multiple namespace support", () => { test("aggregates types from multiple namespaces", async () => { - const userType = db.type("User", { name: db.string() }); - const eventType = db.type("Event", { timestamp: db.datetime() }); + const userType = db.table("User", { name: db.string() }); + const eventType = db.table("Event", { timestamp: db.datetime() }); const result = await runOnTailorDBReady([ { @@ -252,7 +252,7 @@ describe("KyselyTypePlugin integration tests", () => { }); test("includes only necessary utility types", async () => { - const simpleType = db.type("Simple", { name: db.string() }); + const simpleType = db.table("Simple", { name: db.string() }); const result = await runOnTailorDBReady([ { diff --git a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts index b35bd36bc..342abb2c7 100644 --- a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts +++ b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts @@ -20,7 +20,7 @@ async function getTypeDef(type: TailorAnyDBType) { describe("Kysely TypeProcessor", () => { describe("basic types", () => { test("should propagate the type name into the result", async () => { - const type = db.type("User", { + const type = db.table("User", { name: db.string(), }); @@ -32,7 +32,7 @@ describe("Kysely TypeProcessor", () => { test.each([ { name: "string types", - type: db.type("User", { + type: db.table("User", { name: db.string(), nickname: db.string({ optional: true }), }), @@ -40,7 +40,7 @@ describe("Kysely TypeProcessor", () => { }, { name: "number types", - type: db.type("Product", { + type: db.table("Product", { quantity: db.int(), price: db.float(), discount: db.float({ optional: true }), @@ -49,7 +49,7 @@ describe("Kysely TypeProcessor", () => { }, { name: "boolean types", - type: db.type("Feature", { + type: db.table("Feature", { enabled: db.bool(), beta: db.bool({ optional: true }), }), @@ -57,7 +57,7 @@ describe("Kysely TypeProcessor", () => { }, { name: "date and datetime types", - type: db.type("Event", { + type: db.table("Event", { startDate: db.date(), endDate: db.datetime(), cancelledAt: db.datetime({ optional: true }), @@ -70,7 +70,7 @@ describe("Kysely TypeProcessor", () => { }, { name: "uuid types", - type: db.type("Session", { + type: db.table("Session", { userId: db.uuid(), deviceId: db.uuid({ optional: true }), }), @@ -85,7 +85,7 @@ describe("Kysely TypeProcessor", () => { describe("array types", () => { test("should handle array fields", async () => { const typeDef = await getTypeDef( - db.type("Post", { + db.table("Post", { tags: db.string({ array: true }), scores: db.int({ array: true, optional: true }), }), @@ -97,7 +97,7 @@ describe("Kysely TypeProcessor", () => { test("should use ArrayColumnType for datetime array fields", async () => { const typeDef = await getTypeDef( - db.type("Event", { + db.table("Event", { eventDates: db.datetime({ array: true }), optionalDates: db.date({ array: true, optional: true }), }), @@ -111,7 +111,7 @@ describe("Kysely TypeProcessor", () => { describe("enum types", () => { test("should handle enum types", async () => { const typeDef = await getTypeDef( - db.type("User", { + db.table("User", { role: db.enum([{ value: "admin" }, { value: "user" }]), status: db.enum([{ value: "active" }, { value: "inactive" }], { optional: true, @@ -125,7 +125,7 @@ describe("Kysely TypeProcessor", () => { test("should handle enum array types", async () => { const typeDef = await getTypeDef( - db.type("Article", { + db.table("Article", { categories: db.enum(["tech", "health", "finance"], { array: true }), authors: db.enum(["alice", "bob"], { array: true, optional: true }), }), @@ -138,7 +138,7 @@ describe("Kysely TypeProcessor", () => { describe("nested objects", () => { test("should handle single level nested objects", async () => { - const simpleNestedType = db.type("SimpleUser", { + const simpleNestedType = db.table("SimpleUser", { profile: db.object({ name: db.string(), email: db.string({ optional: true }), @@ -156,7 +156,7 @@ describe("Kysely TypeProcessor", () => { }); test("should handle multi-level nested objects", async () => { - const deepNestedType = db.type("Company", { + const deepNestedType = db.table("Company", { details: db.object({ // @ts-expect-error: Nested objects have complex type inference address: db.object({ @@ -184,8 +184,8 @@ describe("Kysely TypeProcessor", () => { expect(typeDef).toContain("phone?: string | null"); }); - test("should use Date | string instead of Timestamp for date fields inside nested objects", async () => { - const type = db.type("Receipt", { + test("should use Timestamp for date fields inside nested objects", async () => { + const type = db.table("Receipt", { receiptDate: db.date(), dueSchedule: db.object({ dueDate: db.date(), @@ -205,7 +205,7 @@ describe("Kysely TypeProcessor", () => { test("should wrap nested object arrays with ArrayColumnType>", async () => { const typeDef = await getTypeDef( - db.type("Profile", { + db.table("Profile", { metadata: db.object( { created: db.datetime(), @@ -223,7 +223,7 @@ describe("Kysely TypeProcessor", () => { test("should handle optional nested object arrays", async () => { const typeDef = await getTypeDef( - db.type("Profile", { + db.table("Profile", { tags: db.object( { name: db.string(), @@ -240,7 +240,7 @@ describe("Kysely TypeProcessor", () => { test("should use plain array syntax for nested objects without ColumnType fields", async () => { const typeDef = await getTypeDef( - db.type("Profile", { + db.table("Profile", { tags: db.object( { name: db.string(), @@ -258,7 +258,7 @@ describe("Kysely TypeProcessor", () => { test("should handle optional nested objects", async () => { const typeDef = await getTypeDef( - db.type("User", { + db.table("User", { settings: db.object( { theme: db.string(), @@ -276,7 +276,7 @@ describe("Kysely TypeProcessor", () => { describe("special fields", () => { test("should process timestamp fields through normal field processing", async () => { - const typeWithTimestamps = db.type("UserWithTimestamp", { + const typeWithTimestamps = db.table("UserWithTimestamp", { name: db.string(), ...db.fields.timestamps(), }); @@ -287,12 +287,24 @@ describe("Kysely TypeProcessor", () => { expect(result.typeDef).toContain("UserWithTimestamp: {"); expect(result.typeDef).toContain("name: string"); expect(result.typeDef).toContain("createdAt: Generated;"); - expect(result.typeDef).toContain("updatedAt: Timestamp | null;"); + expect(result.typeDef).toContain("updatedAt: Generated;"); + }); + + test("should wrap fields with default in Generated<>", async () => { + const typeDef = await getTypeDef( + db.table("Order", { + status: db.string().default("pending"), + priority: db.int(), + }), + ); + + expect(typeDef).toContain("status: Generated;"); + expect(typeDef).toContain("priority: number;"); }); test("should always include Generated for id field", async () => { const typeDef = await getTypeDef( - db.type("User", { + db.table("User", { name: db.string(), }), ); @@ -303,25 +315,25 @@ describe("Kysely TypeProcessor", () => { test.each([ { name: "basic types only", - type: db.type("User", { name: db.string(), age: db.int() }), + type: db.table("User", { name: db.string(), age: db.int() }), timestamp: false, serial: false, }, { name: "Timestamp", - type: db.type("User", { name: db.string(), ...db.fields.timestamps() }), + type: db.table("User", { name: db.string(), ...db.fields.timestamps() }), timestamp: true, serial: false, }, { name: "Serial", - type: db.type("Invoice", { invoiceNumber: db.string().serial({ start: 1000 }) }), + type: db.table("Invoice", { invoiceNumber: db.string().serial({ start: 1000 }) }), timestamp: false, serial: true, }, { name: "both", - type: db.type("Order", { + type: db.table("Order", { orderNumber: db.string().serial({ start: 1000 }), ...db.fields.timestamps(), }), diff --git a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts index 024487e05..34522e94b 100644 --- a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts +++ b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts @@ -63,7 +63,10 @@ function getNestedType(fieldConfig: OperatorFieldConfig): FieldTypeResult { const obj = `{\n ${fieldTypes.join(";\n ")}${fieldTypes.length > 0 ? ";" : ""}\n}`; const hasOptionalFields = Object.values(fields).some((config) => config.required !== true); - if (aggregatedUtilityTypes.Timestamp || hasOptionalFields) { + const hasGeneratedFields = Object.values(fields).some( + (config) => config.hooks?.create || config.default !== undefined, + ); + if (aggregatedUtilityTypes.Timestamp || hasOptionalFields || hasGeneratedFields) { return { type: `ObjectColumnType<${obj}>`, usedUtilityTypes: aggregatedUtilityTypes }; } return { type: obj, usedUtilityTypes: aggregatedUtilityTypes }; @@ -149,7 +152,7 @@ function generateFieldType(fieldConfig: OperatorFieldConfig): FieldTypeResult { usedUtilityTypes.Serial = true; finalType = `Serial<${finalType}>`; } - if (fieldConfig.hooks?.create) { + if (fieldConfig.hooks?.create || fieldConfig.default !== undefined) { finalType = `Generated<${finalType}>`; } diff --git a/packages/sdk/src/plugin/builtin/registry.ts b/packages/sdk/src/plugin/builtin/registry.ts deleted file mode 100644 index 8a2e4cba1..000000000 --- a/packages/sdk/src/plugin/builtin/registry.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { enumConstantsPlugin, EnumConstantsGeneratorID } from "./enum-constants"; -import { fileUtilsPlugin, FileUtilsGeneratorID } from "./file-utils"; -import { kyselyTypePlugin, KyselyGeneratorID } from "./kysely-type"; -import { seedPlugin, SeedGeneratorID } from "./seed"; -import type { Plugin } from "#/plugin/types"; - -// Map of builtin generator IDs to plugin factory functions -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- builtin plugins accept heterogeneous options -export const builtinPlugins = new Map Plugin>([ - [KyselyGeneratorID, (options) => kyselyTypePlugin(options)], - [SeedGeneratorID, (options) => seedPlugin(options)], - [EnumConstantsGeneratorID, (options) => enumConstantsPlugin(options)], - [FileUtilsGeneratorID, (options) => fileUtilsPlugin(options)], -]); diff --git a/packages/sdk/src/plugin/builtin/seed/index.ts b/packages/sdk/src/plugin/builtin/seed/index.ts index 174141fdb..fee5193af 100644 --- a/packages/sdk/src/plugin/builtin/seed/index.ts +++ b/packages/sdk/src/plugin/builtin/seed/index.ts @@ -100,7 +100,7 @@ function generateIdpUserSeedFunction(hasIdpUser: boolean, idpNamespace: string | workspaceId, name: "seed-idp-user.ts", code: idpSeedCode, - arg: JSON.stringify({ users: rows }), + arg: { users: rows }, invoker: { namespace: authNamespace, machineUserName, @@ -186,7 +186,6 @@ function generateIdpUserTruncateFunction(hasIdpUser: boolean, idpNamespace: stri workspaceId, name: "truncate-idp-user.ts", code: idpTruncateCode, - arg: JSON.stringify({}), invoker: { namespace: authNamespace, machineUserName, @@ -674,7 +673,7 @@ ${namespaceSelfRefEntries} workspaceId, name: \`seed-\${namespace}.ts\`, code: bundled.bundledCode, - arg: JSON.stringify({ data: chunk.data, order: chunk.order, selfRefTypes }), + arg: { data: chunk.data, order: chunk.order, selfRefTypes }, invoker: { namespace: authNamespace, machineUserName, diff --git a/packages/sdk/src/plugin/compat.test.ts b/packages/sdk/src/plugin/compat.test.ts deleted file mode 100644 index b57b6bc83..000000000 --- a/packages/sdk/src/plugin/compat.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { randomUUID } from "node:crypto"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { beforeAll, describe, expect, test } from "vitest"; -import { generate } from "#/cli/commands/generate/service"; - -describe("defineGenerators and definePlugins produce identical output", () => { - const fixtureDir = path.resolve(__dirname, "../cli/commands/deploy/__test_fixtures__"); - const generatorsDir = path.join(fixtureDir, "generators-compat-out"); - const pluginsDir = path.join(fixtureDir, "plugins-compat-out"); - - const collectFiles = (rootDir: string): string[] => { - const files: string[] = []; - const traverse = (currentDir: string) => { - const entries = fs.readdirSync(currentDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.name === ".DS_Store") continue; - const fullPath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - traverse(fullPath); - } else { - files.push(path.relative(rootDir, fullPath).split(path.sep).join("/")); - } - } - }; - traverse(rootDir); - return files.toSorted(); - }; - - beforeAll(async () => { - process.env.TAILOR_PLATFORM_WORKSPACE_ID ??= randomUUID(); - - for (const dir of [generatorsDir, pluginsDir]) { - if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true }); - } - - await generate({ - configPath: path.join(fixtureDir, "tailor.config.generators-compat.ts"), - }); - await generate({ - configPath: path.join(fixtureDir, "tailor.config.plugins-compat.ts"), - }); - }, 120000); - - test("plugin output includes all generated files from defineGenerators", () => { - const generatorFiles = collectFiles(generatorsDir); - const pluginFiles = collectFiles(pluginsDir); - expect(generatorFiles.length).toBeGreaterThan(0); - for (const file of generatorFiles) { - expect(pluginFiles, `File ${file} missing from plugins output`).toContain(file); - } - }); - - test("all files have identical content", () => { - const files = collectFiles(generatorsDir); - expect(files.length).toBeGreaterThan(0); - - const normalizeConfigPath = (content: string) => - content.replace( - /tailor\.config\.(generators|plugins)-compat\.ts/g, - "tailor.config.compat.ts", - ); - - for (const file of files) { - const generatorContent = normalizeConfigPath( - fs.readFileSync(path.join(generatorsDir, file), "utf-8"), - ); - const pluginContent = normalizeConfigPath( - fs.readFileSync(path.join(pluginsDir, file), "utf-8"), - ); - expect(pluginContent, `Content mismatch in ${file}`).toBe(generatorContent); - } - }); -}); diff --git a/packages/sdk/src/plugin/manager.test.ts b/packages/sdk/src/plugin/manager.test.ts index 1120052ac..8dbd63e31 100644 --- a/packages/sdk/src/plugin/manager.test.ts +++ b/packages/sdk/src/plugin/manager.test.ts @@ -3,7 +3,7 @@ import { db } from "#/configure/services/tailordb/index"; import { PluginManager } from "#/plugin/manager"; import type { Plugin } from "#/plugin/types"; -const orderType = () => db.type("Order", { name: db.string() }); +const orderType = () => db.table("Order", { name: db.string() }); describe("PluginManager", () => { test("collects namespace plugin-generated types", async () => { @@ -13,7 +13,7 @@ describe("PluginManager", () => { importPath: "@example/namespace", onNamespaceLoaded: () => ({ types: { - auditLog: db.type("AuditLog", { + auditLog: db.table("AuditLog", { message: db.string(), }), }, @@ -42,7 +42,7 @@ describe("PluginManager", () => { importPath: "@example/namespace", onNamespaceLoaded: () => ({ types: { - auditLog: db.type("AuditLog", { + auditLog: db.table("AuditLog", { message: db.string(), }), }, @@ -67,7 +67,7 @@ describe("PluginManager", () => { test("preserves pluralForm and plugin attachments when extending types", () => { const manager = new PluginManager(); const original = db - .type(["Person", "People"], { + .table(["Person", "People"], { name: db.string(), }) // PluginConfigs is open; use cast to attach plugin config in tests. @@ -116,7 +116,7 @@ describe("PluginManager", () => { importPath: "@example/schema-less", onTypeLoaded: (_context: Parameters>[0]) => ({ types: { - derived: db.type("Derived", { + derived: db.table("Derived", { sourceId: db.uuid(), customValue: db.string(), }), diff --git a/packages/sdk/src/plugin/manager.ts b/packages/sdk/src/plugin/manager.ts index 79f71ad85..c32b1a780 100644 --- a/packages/sdk/src/plugin/manager.ts +++ b/packages/sdk/src/plugin/manager.ts @@ -5,8 +5,8 @@ import type { TailorTypePermission, TailorTypeGqlPermission, } from "#/configure/services/tailordb/permission"; -import type { DependencyKind } from "#/parser/generator-config/schema"; import type { + DependencyKind, Plugin, PluginAttachment, PluginGeneratedExecutor, @@ -509,7 +509,7 @@ export class PluginManager { /** * Extend a TailorDB type with new fields. - * This method handles the `db.type()` call and metadata copying internally. + * This method handles the `db.table()` call and metadata copying internally. * @param params - Parameters for type extension * @returns The extended TailorDB type */ @@ -536,7 +536,7 @@ export class PluginManager { const typeName = pluralForm ? ([originalType.name, pluralForm] as [string, string]) : originalType.name; - const extendedType = db.type(typeName, fieldsWithoutId); + const extendedType = db.table(typeName, fieldsWithoutId); return copyMetadataToExtendedType(originalType, extendedType); } } @@ -563,7 +563,7 @@ export interface PluginTypeGenerationResult { * Parameters for generating plugin files */ export interface GeneratePluginFilesParams { - /** Base output directory (e.g., .tailor-sdk/plugin) */ + /** Base output directory (e.g., .tailor/plugin) */ outputDir: string; /** Map of source type names to their source info */ sourceTypeInfoMap: Map; diff --git a/packages/sdk/src/plugin/types.ts b/packages/sdk/src/plugin/types.ts index fbd1c434e..5ea57a640 100644 --- a/packages/sdk/src/plugin/types.ts +++ b/packages/sdk/src/plugin/types.ts @@ -4,19 +4,8 @@ // This is a pure type module: type declarations only, no zod/schema // references, importable type-only from any layer. -import type { - BaseGeneratorConfigInput, - CodeGeneratorInput, -} from "#/types/generator-config.generated"; - export type DependencyKind = "tailordb" | "resolver" | "executor"; -export type GeneratorConfig = BaseGeneratorConfigInput; - -export type CodeGeneratorBase = Omit & { - dependencies: readonly DependencyKind[]; -}; - import type { PluginAttachment, TailorAnyDBField, diff --git a/packages/sdk/src/plugin/with-context.ts b/packages/sdk/src/plugin/with-context.ts index 0bbacf2c4..87fe583a9 100644 --- a/packages/sdk/src/plugin/with-context.ts +++ b/packages/sdk/src/plugin/with-context.ts @@ -4,7 +4,7 @@ * context (like type references and namespace) at runtime. */ -import type { TailorActor, TailorEnv } from "#/runtime/types"; +import type { TailorEnv, TailorPrincipal } from "#/runtime/types"; /** * Plugin executor factory function type. @@ -28,8 +28,8 @@ export interface PluginFunctionArgs { appNamespace: string; /** Environment variables */ env: TailorEnv; - /** Actor (user) who triggered the event, null for system events */ - actor: TailorActor | null; + /** Principal that triggered the event, null for system events */ + actor: TailorPrincipal | null; /** Name of the TailorDB type */ typeName: string; /** TailorDB connections by namespace */ diff --git a/packages/sdk/src/runtime/aigateway.test.ts b/packages/sdk/src/runtime/aigateway.test.ts index 70c49c32a..a55374fe6 100644 --- a/packages/sdk/src/runtime/aigateway.test.ts +++ b/packages/sdk/src/runtime/aigateway.test.ts @@ -2,7 +2,7 @@ * Tests for `@tailor-platform/sdk/runtime/aigateway` typed wrappers. */ import { afterEach, beforeEach, describe, expect, expectTypeOf, test } from "vitest"; -import * as aigateway from "#/runtime/aigateway"; +import { aigateway, type GetAIGatewayResult } from "#/runtime/aigateway"; import { cleanupMocks, injectMocks, mockAigateway } from "#/vitest/mock"; describe("@tailor-platform/sdk/runtime/aigateway", () => { @@ -20,7 +20,7 @@ describe("@tailor-platform/sdk/runtime/aigateway", () => { const result = aigateway.get("my-aigateway"); - expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result).toEqualTypeOf>(); await expect(result).resolves.toEqual({ url: "https://my-aigateway.example.com" }); expect(ag.calls).toEqual([{ name: "my-aigateway" }]); }); diff --git a/packages/sdk/src/runtime/aigateway.ts b/packages/sdk/src/runtime/aigateway.ts index 0e91d3857..23fd366fd 100644 --- a/packages/sdk/src/runtime/aigateway.ts +++ b/packages/sdk/src/runtime/aigateway.ts @@ -6,7 +6,7 @@ * from `@tailor-platform/sdk/vitest` to mock these calls in unit tests. * * `name` is narrowed to the AI Gateway names defined via `defineAIGateway()` - * once `tailor.d.ts` has been generated (via `tailor-sdk deploy`/`generate`). + * once `tailor.d.ts` has been generated (via `tailor deploy`/`generate`). * @example * import { aigateway } from "@tailor-platform/sdk/runtime"; * @@ -28,10 +28,6 @@ export interface GetAIGatewayResult { /** * Platform API surface for `tailor.aigateway`. Describes the shape the * platform runtime injects on `globalThis.tailor.aigateway`. - * - * Each method below is also re-exported as a top-level named export from this - * module so callers can either `import * as aigateway from - * "@tailor-platform/sdk/runtime/aigateway"` or pick individual methods. */ export interface TailorAigatewayAPI { /** @@ -43,11 +39,9 @@ export interface TailorAigatewayAPI { } const api = (): TailorAigatewayAPI => - (globalThis as { tailor: { aigateway: TailorAigatewayAPI } }).tailor.aigateway; + (globalThis as unknown as { tailor: { aigateway: TailorAigatewayAPI } }).tailor.aigateway; -/** - * See {@link TailorAigatewayAPI.get}. - * @param args - Forwarded to {@link TailorAigatewayAPI.get} - * @returns The resolved AI Gateway's platform-assigned URL - */ -export const get: TailorAigatewayAPI["get"] = (...args) => api().get(...args); +const get: TailorAigatewayAPI["get"] = (...args) => api().get(...args); + +/** Runtime wrapper namespace for `tailor.aigateway`. */ +export const aigateway = { get } as const satisfies TailorAigatewayAPI; diff --git a/packages/sdk/src/runtime/authconnection.test.ts b/packages/sdk/src/runtime/authconnection.test.ts index a27bd41a3..1236006f9 100644 --- a/packages/sdk/src/runtime/authconnection.test.ts +++ b/packages/sdk/src/runtime/authconnection.test.ts @@ -2,7 +2,7 @@ * Tests for `@tailor-platform/sdk/runtime/authconnection` typed wrappers. */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import * as authconnection from "#/runtime/authconnection"; +import { authconnection } from "#/runtime/authconnection"; import { mockAuthconnection, cleanupMocks, injectMocks } from "#/vitest/mock"; describe("@tailor-platform/sdk/runtime/authconnection", () => { diff --git a/packages/sdk/src/runtime/authconnection.ts b/packages/sdk/src/runtime/authconnection.ts index 9e0aa7b62..324fd4e1d 100644 --- a/packages/sdk/src/runtime/authconnection.ts +++ b/packages/sdk/src/runtime/authconnection.ts @@ -6,7 +6,7 @@ * `mockAuthconnection` from `@tailor-platform/sdk/vitest` to mock in unit tests. * * `connectionName` is narrowed to the connection names defined in `defineAuth()`'s - * `connections` once `tailor.d.ts` has been generated (via `tailor-sdk deploy`/`generate`). + * `connections` once `tailor.d.ts` has been generated (via `tailor deploy`/`generate`). * @example * import { authconnection } from "@tailor-platform/sdk/runtime"; * @@ -22,10 +22,6 @@ import type { ConnectionName } from "@tailor-platform/sdk"; /** * Platform API surface for `tailor.authconnection`. Describes the shape the * platform runtime injects on `globalThis.tailor.authconnection`. - * - * Each method below is also re-exported as a top-level named export from this - * module so callers can either `import * as authconnection from - * "@tailor-platform/sdk/runtime/authconnection"` or pick individual methods. */ export interface TailorAuthconnectionAPI { /** @@ -37,12 +33,11 @@ export interface TailorAuthconnectionAPI { } const api = (): TailorAuthconnectionAPI => - (globalThis as { tailor: { authconnection: TailorAuthconnectionAPI } }).tailor.authconnection; + (globalThis as unknown as { tailor: { authconnection: TailorAuthconnectionAPI } }).tailor + .authconnection; -/** - * See {@link TailorAuthconnectionAPI.getConnectionToken}. - * @param args - Forwarded to {@link TailorAuthconnectionAPI.getConnectionToken} - * @returns Token payload - */ -export const getConnectionToken: TailorAuthconnectionAPI["getConnectionToken"] = (...args) => +const getConnectionToken: TailorAuthconnectionAPI["getConnectionToken"] = (...args) => api().getConnectionToken(...args); + +/** Runtime wrapper namespace for `tailor.authconnection`. */ +export const authconnection = { getConnectionToken } as const satisfies TailorAuthconnectionAPI; diff --git a/packages/sdk/src/runtime/context.test.ts b/packages/sdk/src/runtime/context.test.ts index 3de72d57e..7fe2356fc 100644 --- a/packages/sdk/src/runtime/context.test.ts +++ b/packages/sdk/src/runtime/context.test.ts @@ -2,7 +2,7 @@ * Tests for `@tailor-platform/sdk/runtime/context` typed wrappers. */ import { afterEach, beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; -import * as context from "#/runtime/context"; +import { context, type Invoker, type TailorContextAPI } from "#/runtime/context"; import { cleanupMocks, injectMocks } from "#/vitest/mock"; describe("@tailor-platform/sdk/runtime/context", () => { @@ -14,10 +14,15 @@ describe("@tailor-platform/sdk/runtime/context", () => { cleanupMocks(globalThis); }); + test("exposes the normalized wrapper contract", () => { + expectTypeOf(context).toExtend(); + expectTypeOf>().toEqualTypeOf(); + }); + test("getInvoker returns null for anonymous invocations", () => { const result = context.getInvoker(); - expectTypeOf(result).toEqualTypeOf(); + expectTypeOf(result).toEqualTypeOf(); expect(result).toBeNull(); }); diff --git a/packages/sdk/src/runtime/context.ts b/packages/sdk/src/runtime/context.ts index 5fac684a5..1102558b9 100644 --- a/packages/sdk/src/runtime/context.ts +++ b/packages/sdk/src/runtime/context.ts @@ -12,24 +12,15 @@ * } */ +import type { TailorPrincipal } from "#/runtime/types"; + /** * Information about the invoker of the current function execution. * - * Matches the shape of `TailorUser` and `TailorActor` — `attributes` is the - * attribute map and `attributeList` is the array of attribute IDs. + * Matches the public `TailorPrincipal` shape — `attributes` is the attribute + * map and `attributeList` is the array of attribute IDs. */ -export interface Invoker { - /** The invoker's ID */ - id: string; - /** The invoker's type */ - type: "user" | "machine_user"; - /** The workspace ID */ - workspaceId: string; - /** A map of the invoker's attributes */ - attributes: Record; - /** The list of attribute IDs */ - attributeList: string[]; -} +export type Invoker = TailorPrincipal; /** * Raw platform-side invoker payload returned by `tailor.context.getInvoker()`. @@ -54,23 +45,37 @@ export interface ContextInvoker { * runtime injects on `globalThis.tailor.context`. * @internal */ -export interface TailorContextAPI { +export interface PlatformContextAPI { getInvoker(): ContextInvoker | null; } +/** Runtime wrapper API for execution context utilities. */ +export interface TailorContextAPI { + /** + * Returns information about the current invoker. + * @returns Invoker details, or `null` for anonymous invocations + */ + getInvoker(): Invoker | null; +} + /** * Returns information about the invoker of the current function execution, * or `null` for anonymous invocations. * @returns Invoker details, or `null` when the call is anonymous */ -export function getInvoker(): Invoker | null { - const raw = (globalThis as { tailor: { context: TailorContextAPI } }).tailor.context.getInvoker(); +function getInvoker(): Invoker | null { + const raw = ( + globalThis as unknown as { tailor: { context: PlatformContextAPI } } + ).tailor.context.getInvoker(); if (!raw) return null; return { id: raw.id, type: raw.type, workspaceId: raw.workspaceId, - attributes: raw.attributeMap, - attributeList: raw.attributes, + attributes: raw.attributeMap as Invoker["attributes"], + attributeList: raw.attributes as Invoker["attributeList"], }; } + +/** Runtime wrapper namespace for `tailor.context`. */ +export const context = { getInvoker } as const satisfies TailorContextAPI; diff --git a/packages/sdk/src/runtime/field-parse.test.ts b/packages/sdk/src/runtime/field-parse.test.ts index 7fd31264d..eeea5a594 100644 --- a/packages/sdk/src/runtime/field-parse.test.ts +++ b/packages/sdk/src/runtime/field-parse.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "vitest"; import { parseInputFields, type FieldRuntime } from "./field-parse"; -import type { TailorUser } from "./types"; +import type { TailorPrincipal } from "./types"; -const user: TailorUser = { +const invoker: TailorPrincipal = { id: "00000000-0000-0000-0000-000000000000", - type: "", + type: "machine_user", workspaceId: "", - attributes: null, + attributes: {}, attributeList: [], }; @@ -22,7 +22,7 @@ describe("parseInputFields", () => { fields: { name: stringField() }, value: { name: "a" }, data: {}, - user, + invoker, }); expect(result.issues).toBeUndefined(); }); @@ -32,7 +32,7 @@ describe("parseInputFields", () => { fields: { name: stringField(), age: stringField() }, value: {}, data: {}, - user, + invoker, }); expect(result.issues).toEqual([ { message: "Required field is missing", path: ["name"] }, @@ -45,33 +45,36 @@ describe("parseInputFields", () => { fields: { name: stringField({ required: true, - validate: [[({ value }) => typeof value === "string" && value.length > 2, "Too short"]], + validate: [ + ({ value }) => + typeof value === "string" && value.length > 2 ? undefined : "Too short", + ], }), }, value: { name: "ab" }, data: {}, - user, + invoker, }); expect(result.issues).toEqual([{ message: "Too short", path: ["name"] }]); }); test("rejects a top-level array even when all fields are optional", () => { - const result = parseInputFields({ fields: {}, value: [1, 2, 3], data: {}, user }); + const result = parseInputFields({ fields: {}, value: [1, 2, 3], data: {}, invoker }); expect(result.issues).toEqual([{ message: "Expected an object: received 1,2,3" }]); }); test("rejects a missing top-level value as a required field", () => { - const result = parseInputFields({ fields: {}, value: null, data: {}, user }); + const result = parseInputFields({ fields: {}, value: null, data: {}, invoker }); expect(result.issues).toEqual([{ message: "Required field is missing" }]); }); test("rejects primitive top-level values", () => { - const result = parseInputFields({ fields: {}, value: "hello", data: {}, user }); + const result = parseInputFields({ fields: {}, value: "hello", data: {}, invoker }); expect(result.issues).toEqual([{ message: "Expected an object: received hello" }]); }); test("rejects a Date as a top-level value", () => { - const result = parseInputFields({ fields: {}, value: new Date(), data: {}, user }); + const result = parseInputFields({ fields: {}, value: new Date(), data: {}, invoker }); expect(result.issues?.[0]?.message).toMatch(/^Expected an object:/); }); }); diff --git a/packages/sdk/src/runtime/field-parse.ts b/packages/sdk/src/runtime/field-parse.ts index d01da6b22..47b03c63f 100644 --- a/packages/sdk/src/runtime/field-parse.ts +++ b/packages/sdk/src/runtime/field-parse.ts @@ -1,26 +1,26 @@ import type { FieldMetadata, TailorFieldType } from "#/configure/types/field.types"; -import type { TailorUser } from "./types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { StandardSchemaV1 } from "@standard-schema/spec"; const regex = { uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, date: /^(?\d{4})-(?\d{2})-(?\d{2})$/, - time: /^(?\d{2}):(?\d{2})$/, + time: /^(?[01]\d|2[0-3]):(?[0-5]\d)$/, datetime: - /^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(\.(?\d{3}))?Z$/, + /^(?\d{4})-(?\d{2})-(?\d{2})[Tt](?[01]\d|2[0-3]):(?[0-5]\d):(?[0-5]\d|60)(\.(?\d+))?(?[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, decimal: /^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/, } as const; export type FieldParseArgs = { value: unknown; data: unknown; - user: TailorUser; + invoker: TailorPrincipal | null; }; export type FieldParseInternalArgs = { value: unknown; data: unknown; - user: TailorUser; + invoker: TailorPrincipal | null; pathArray: string[]; }; @@ -175,18 +175,14 @@ function validateCustomValue( args: FieldValidationArgs, validateFns: NonNullable, ): void { - const { value, data, user, pathArray, issues } = args; + const { value, pathArray, issues } = args; const path = pathArray.length > 0 ? pathArray : undefined; - for (const validateInput of validateFns) { - const { fn, message } = - typeof validateInput === "function" - ? { fn: validateInput, message: "Validation failed" } - : { fn: validateInput[0], message: validateInput[1] }; - - if (!fn({ value, data, user })) { + for (const fn of validateFns) { + const result = fn({ value }); + if (typeof result === "string") { issues.push({ - message, + message: result, path, }); } diff --git a/packages/sdk/src/runtime/file.test.ts b/packages/sdk/src/runtime/file.test.ts index 832fd98ca..ff3fcea09 100644 --- a/packages/sdk/src/runtime/file.test.ts +++ b/packages/sdk/src/runtime/file.test.ts @@ -2,7 +2,7 @@ * Tests for `@tailor-platform/sdk/runtime/file` typed wrappers. */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import * as file from "#/runtime/file"; +import { file, type TailorDBFileError, type TailorDBFileErrorCode } from "#/runtime/file"; import { cleanupMocks, mockFile, injectMocks } from "#/vitest/mock"; const args = ["ns", "Doc", "blob", "rec-1"] as const; @@ -84,35 +84,15 @@ describe("@tailor-platform/sdk/runtime/file", () => { expect(fileM.calls[0]?.method).toBe("getMetadata"); }); - test("delete forwards (re-exported from deleteFile)", async () => { + test("delete forwards", async () => { using fileM = mockFile(); await file.delete(...args); expect(fileM.calls).toEqual([expectedCall("delete")]); }); - test("openDownloadStream forwards and yields StreamValue chunks", async () => { - using fileM = mockFile(); - const sequence: file.StreamValue[] = [ - { - type: "metadata", - metadata: { contentType: "application/octet-stream", fileSize: 2, sha256sum: "h" }, - }, - { type: "chunk", data: new Uint8Array([1]), position: 0 }, - { type: "chunk", data: new Uint8Array([2]), position: 1 }, - { type: "complete" }, - ]; - fileM.enqueueResult(sequence); - - const stream = await file.openDownloadStream(...args); - - const chunks: file.StreamValue[] = []; - for await (const chunk of stream) { - chunks.push(chunk); - } - - expect(chunks).toEqual(sequence); - expect(fileM.calls[0]?.method).toBe("openDownloadStream"); + test("does not export the removed openDownloadStream wrapper", () => { + expect("openDownloadStream" in file).toBe(false); }); test("downloadStream forwards and returns body with metadata", async () => { @@ -161,8 +141,8 @@ describe("@tailor-platform/sdk/runtime/file", () => { globalThis as unknown as { TailorDBFileError: new ( m: string, - c?: file.TailorDBFileErrorCode, - ) => Error & { code?: file.TailorDBFileErrorCode }; + c?: TailorDBFileErrorCode, + ) => Error & { code?: TailorDBFileErrorCode }; } ).TailorDBFileError; const err = new TailorDBFileError("operation failed", "OPERATION_FAILED"); @@ -170,7 +150,7 @@ describe("@tailor-platform/sdk/runtime/file", () => { expect(err.code).toBe("OPERATION_FAILED"); // Type-level: file.TailorDBFileError is a structural interface that the // global class instances satisfy (not a direct alias of the class itself). - const _typed: file.TailorDBFileError = err as file.TailorDBFileError; + const _typed: TailorDBFileError = err as TailorDBFileError; expect(_typed).toBe(err); }); }); diff --git a/packages/sdk/src/runtime/file.ts b/packages/sdk/src/runtime/file.ts index d062b038a..179b2b1e0 100644 --- a/packages/sdk/src/runtime/file.ts +++ b/packages/sdk/src/runtime/file.ts @@ -30,7 +30,7 @@ export interface DownloadMetadata { lastUploadedAt: string; } -/** File metadata (for {@link getMetadata}). */ +/** File metadata (for {@link TailorDBFileAPI.getMetadata}). */ export interface FileMetadata { contentType: string; fileSize: number; @@ -39,13 +39,6 @@ export interface FileMetadata { lastUploadedAt?: string; } -/** Stream metadata (first chunk emitted by {@link openDownloadStream}). */ -export interface StreamMetadata { - contentType: string; - fileSize: number; - sha256sum: string; -} - /** Upload options. */ export interface FileUploadOptions { contentType?: string; @@ -80,18 +73,6 @@ export interface FileDownloadStreamResponse { metadata: DownloadMetadata; } -/** Stream chunk types emitted by {@link FileStreamIterator}. */ -export type StreamValue = - | { type: "metadata"; metadata: StreamMetadata } - | { type: "chunk"; data: Uint8Array; position: number } - | { type: "complete" }; - -/** Stream iterator returned by {@link openDownloadStream}. */ -export interface FileStreamIterator extends AsyncIterableIterator { - next(): Promise>; - close(): Promise; -} - /** Error code emitted by {@link TailorDBFileError}. */ export type TailorDBFileErrorCode = | "INVALID_PARAMS" @@ -118,11 +99,6 @@ export interface TailorDBFileError extends Error { /** * Platform API surface for `tailordb.file`. Describes the shape the platform * runtime injects on `globalThis.tailordb.file`. - * - * Each method below is also re-exported as a top-level named export from this - * module (e.g. `upload`, `download`, `deleteFile`) so callers can either - * `import * as file from "@tailor-platform/sdk/runtime/file"` or pick - * individual methods. */ export interface TailorDBFileAPI { /** @@ -181,8 +157,7 @@ export interface TailorDBFileAPI { ): Promise; /** - * Delete a file from TailorDB. Exported as `deleteFile` (and aliased as - * `delete`) so it can be used both with named and namespace imports. + * Delete a file from TailorDB. * @param namespace - TailorDB namespace * @param typeName - TailorDB type name * @param fieldName - File field name on the type @@ -206,22 +181,6 @@ export interface TailorDBFileAPI { recordId: string, ): Promise; - /** - * Open a download stream for large files. - * @deprecated Use {@link downloadStream} instead. - * @param namespace - TailorDB namespace - * @param typeName - TailorDB type name - * @param fieldName - File field name on the type - * @param recordId - Record ID owning the field - * @returns Async iterator yielding file chunks; call `close()` to release resources - */ - openDownloadStream( - namespace: string, - typeName: string, - fieldName: string, - recordId: string, - ): Promise; - /** * Download a file as a ReadableStream. * @param namespace - TailorDB namespace @@ -258,28 +217,28 @@ export interface TailorDBFileAPI { } const api = (): TailorDBFileAPI => - (globalThis as { tailordb: { file: TailorDBFileAPI } }).tailordb.file; + (globalThis as unknown as { tailordb: { file: TailorDBFileAPI } }).tailordb.file; /** * See {@link TailorDBFileAPI.upload}. * @param args - Forwarded to {@link TailorDBFileAPI.upload} * @returns Upload response containing the file metadata */ -export const upload: TailorDBFileAPI["upload"] = (...args) => api().upload(...args); +const upload: TailorDBFileAPI["upload"] = (...args) => api().upload(...args); /** * See {@link TailorDBFileAPI.download}. * @param args - Forwarded to {@link TailorDBFileAPI.download} * @returns Bytes and metadata for the file */ -export const download: TailorDBFileAPI["download"] = (...args) => api().download(...args); +const download: TailorDBFileAPI["download"] = (...args) => api().download(...args); /** * See {@link TailorDBFileAPI.downloadAsBase64}. * @param args - Forwarded to {@link TailorDBFileAPI.downloadAsBase64} * @returns Base64-encoded contents and metadata for the file */ -export const downloadAsBase64: TailorDBFileAPI["downloadAsBase64"] = (...args) => +const downloadAsBase64: TailorDBFileAPI["downloadAsBase64"] = (...args) => api().downloadAsBase64(...args); /** @@ -287,30 +246,21 @@ export const downloadAsBase64: TailorDBFileAPI["downloadAsBase64"] = (...args) = * @param args - Forwarded to {@link TailorDBFileAPI.delete} * @returns Resolves once the file has been deleted */ -export const deleteFile: TailorDBFileAPI["delete"] = (...args) => api().delete(...args); +const deleteFile: TailorDBFileAPI["delete"] = (...args) => api().delete(...args); /** * See {@link TailorDBFileAPI.getMetadata}. * @param args - Forwarded to {@link TailorDBFileAPI.getMetadata} * @returns Metadata for the stored file */ -export const getMetadata: TailorDBFileAPI["getMetadata"] = (...args) => api().getMetadata(...args); - -/** - * See {@link TailorDBFileAPI.openDownloadStream}. - * @deprecated Use {@link downloadStream} instead. - * @param args - Forwarded to {@link TailorDBFileAPI.openDownloadStream} - * @returns Async iterator yielding file chunks; call `close()` to release resources - */ -export const openDownloadStream: TailorDBFileAPI["openDownloadStream"] = (...args) => - api().openDownloadStream(...args); +const getMetadata: TailorDBFileAPI["getMetadata"] = (...args) => api().getMetadata(...args); /** * See {@link TailorDBFileAPI.downloadStream}. * @param args - Forwarded to {@link TailorDBFileAPI.downloadStream} * @returns ReadableStream body and metadata for the file */ -export const downloadStream: TailorDBFileAPI["downloadStream"] = (...args) => +const downloadStream: TailorDBFileAPI["downloadStream"] = (...args) => api().downloadStream(...args); /** @@ -318,7 +268,15 @@ export const downloadStream: TailorDBFileAPI["downloadStream"] = (...args) => * @param args - Forwarded to {@link TailorDBFileAPI.uploadStream} * @returns Upload response containing the file metadata */ -export const uploadStream: TailorDBFileAPI["uploadStream"] = (...args) => - api().uploadStream(...args); - -export { deleteFile as delete }; +const uploadStream: TailorDBFileAPI["uploadStream"] = (...args) => api().uploadStream(...args); + +/** Runtime wrapper namespace for `tailordb.file`. */ +export const file = { + upload, + download, + downloadAsBase64, + delete: deleteFile, + getMetadata, + downloadStream, + uploadStream, +} as const satisfies TailorDBFileAPI; diff --git a/packages/sdk/src/runtime/globals.test.ts b/packages/sdk/src/runtime/globals.test.ts index f3f1346f2..3f3744b1e 100644 --- a/packages/sdk/src/runtime/globals.test.ts +++ b/packages/sdk/src/runtime/globals.test.ts @@ -1,13 +1,19 @@ /** * Type-level tests confirming that opting into `@tailor-platform/sdk/runtime/globals` - * activates the ambient `tailor.*` / `tailordb` declarations. + * activates the ambient `tailor.*` / `tailordb` declarations, and that the + * removed capital-cased `Tailordb` namespace no longer resolves. * - * These assertions are type-only — they reference `tailor`, `tailordb`, and - * `TailorDBFileError` solely through `typeof` so the test does not require - * the platform runtime to inject those values into the unit test environment. + * These assertions are type-only — the positive checks reference `tailor`, + * `tailordb`, and `TailorDBFileError` without depending on the platform + * runtime injecting those values into the unit test environment. */ import "#/runtime/globals"; import { describe, expectTypeOf, test } from "vitest"; +import type { TailordbCommandType } from "#/runtime/index"; + +// @ts-expect-error Tailordb was removed in v2; use lowercase tailordb.*. +const legacyTailordbQueryResult = null as unknown as Tailordb.QueryResult<{ id: string }>; +void legacyTailordbQueryResult; describe("@tailor-platform/sdk/runtime/globals activates ambient globals", () => { test("tailor.iconv.convert is declared as a function", () => { @@ -20,24 +26,12 @@ describe("@tailor-platform/sdk/runtime/globals activates ambient globals", () => >(); }); - test("tailor.workflow.triggerWorkflow returns Promise", () => { - expectTypeOf>().toEqualTypeOf< - Promise - >(); - }); - test("tailor.workflow.startWorkflow returns Promise", () => { expectTypeOf>().toEqualTypeOf< Promise >(); }); - test("tailor.workflow.resumeWorkflow returns Promise", () => { - expectTypeOf>().toEqualTypeOf< - Promise - >(); - }); - test("tailor.workflow.resumeWorkflowExecution returns Promise", () => { expectTypeOf>().toEqualTypeOf< Promise @@ -56,6 +50,12 @@ describe("@tailor-platform/sdk/runtime/globals activates ambient globals", () => expectTypeOf().toBeFunction(); }); + test("tailordb namespace exposes query helper types", () => { + expectTypeOf>().not.toBeAny(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toBeAny(); + }); + test("TailorDBFileError is declared as a global class", () => { expectTypeOf().not.toBeAny(); }); diff --git a/packages/sdk/src/runtime/globals.ts b/packages/sdk/src/runtime/globals.ts index 4018692a3..a47a24bfd 100644 --- a/packages/sdk/src/runtime/globals.ts +++ b/packages/sdk/src/runtime/globals.ts @@ -48,9 +48,8 @@ import type { UserQuery as IdpUserQuery, } from "./idp"; import type { - AuthInvoker as WorkflowAuthInvoker, + Invoker as WorkflowInvoker, StartWorkflowOptions as WorkflowStartWorkflowOptions, - TriggerWorkflowOptions as WorkflowTriggerWorkflowOptions, } from "./workflow"; type TailorIdpClientConfig = IdpClientConfig; @@ -63,8 +62,7 @@ type TailorIdpUnenrollMfaInput = IdpUnenrollMfaInput; type TailorIdpUpdateUserInput = IdpUpdateUserInput; type TailorIdpUser = IdpUser; type TailorIdpUserQuery = IdpUserQuery; -type TailorWorkflowAuthInvoker = WorkflowAuthInvoker; -type TailorWorkflowTriggerWorkflowOptions = WorkflowTriggerWorkflowOptions; +type TailorWorkflowInvoker = WorkflowInvoker; type TailorWorkflowStartWorkflowOptions = WorkflowStartWorkflowOptions; declare global { @@ -77,40 +75,6 @@ declare global { // eslint-disable-next-line no-var var tailordb: TailordbRuntime; - /** - * @deprecated Use the lowercase `tailordb.*` namespace instead (e.g. - * `tailordb.QueryResult`, `tailordb.CommandType`, - * `typeof tailordb.Client`). This capital-cased namespace is retained - * only for backwards compatibility with `@tailor-platform/function-types` - * and will be removed in v2. Run - * `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` - * to migrate. - */ - namespace Tailordb { - /** - * @deprecated Use `tailordb.Client` (lowercase) instead. - * Will be removed in v2. - */ - class Client { - constructor(config: { namespace: string }); - connect(): Promise; - end(): Promise; - queryObject(sql: string, args?: readonly unknown[]): Promise>; - } - - /** - * @deprecated Use `tailordb.QueryResult` (lowercase) instead. - * Will be removed in v2. - */ - type QueryResult = TailordbQueryResult; - - /** - * @deprecated Use `tailordb.CommandType` (lowercase) instead. - * Will be removed in v2. - */ - type CommandType = TailordbCommandType; - } - namespace tailor { namespace iconv { type Iconv = IconvInstance; @@ -130,10 +94,8 @@ declare global { } namespace workflow { - type AuthInvoker = TailorWorkflowAuthInvoker; + type Invoker = TailorWorkflowInvoker; type StartWorkflowOptions = TailorWorkflowStartWorkflowOptions; - /** @deprecated Use `StartWorkflowOptions` instead. */ - type TriggerWorkflowOptions = TailorWorkflowTriggerWorkflowOptions; } namespace context { diff --git a/packages/sdk/src/runtime/iconv.test.ts b/packages/sdk/src/runtime/iconv.test.ts index b641f9593..d02b3922c 100644 --- a/packages/sdk/src/runtime/iconv.test.ts +++ b/packages/sdk/src/runtime/iconv.test.ts @@ -6,7 +6,7 @@ * `string`, otherwise `Uint8Array`) holds at the type level. */ import { afterEach, beforeEach, describe, expect, expectTypeOf, test } from "vitest"; -import * as iconv from "#/runtime/iconv"; +import { iconv } from "#/runtime/iconv"; import { cleanupMocks, mockIconv, injectMocks } from "#/vitest/mock"; describe("@tailor-platform/sdk/runtime/iconv", () => { diff --git a/packages/sdk/src/runtime/iconv.ts b/packages/sdk/src/runtime/iconv.ts index ebeb732e1..acfeeecc2 100644 --- a/packages/sdk/src/runtime/iconv.ts +++ b/packages/sdk/src/runtime/iconv.ts @@ -31,11 +31,6 @@ export interface IconvConstructor { * Platform API surface for `tailor.iconv`. Describes the shape the platform * runtime injects on `globalThis.tailor.iconv` so the wrapper and ambient * globals stay in sync. - * - * Each method below is also re-exported as a top-level named export from this - * module (e.g. `convert`, `decode`, `encode`) so callers can either - * `import * as iconv from "@tailor-platform/sdk/runtime/iconv"` or pick - * individual methods. */ export interface TailorIconvAPI { /** @@ -89,53 +84,28 @@ export interface TailorIconvAPI { */ encodings(): string[]; - /** Constructor for the stateful {@link Iconv} converter. */ + /** Constructor for the stateful converter. */ Iconv: IconvConstructor; } const api = (): TailorIconvAPI => - (globalThis as { tailor: { iconv: TailorIconvAPI } }).tailor.iconv; + (globalThis as unknown as { tailor: { iconv: TailorIconvAPI } }).tailor.iconv; -/** - * See {@link TailorIconvAPI.convert}. - * @param args - Forwarded to {@link TailorIconvAPI.convert} - * @returns `string` when `toEncoding` is `"UTF8"` or `"UTF-8"`, otherwise `Uint8Array`. - */ -export const convert: TailorIconvAPI["convert"] = (...args) => api().convert(...args); +const convert: TailorIconvAPI["convert"] = (...args) => api().convert(...args); -/** - * See {@link TailorIconvAPI.convertBuffer}. - * @param args - Forwarded to {@link TailorIconvAPI.convertBuffer} - * @returns `string` when `toEncoding` is `"UTF8"` or `"UTF-8"`, otherwise `Uint8Array`. - */ -export const convertBuffer: TailorIconvAPI["convertBuffer"] = (...args) => - api().convertBuffer(...args); +const convertBuffer: TailorIconvAPI["convertBuffer"] = (...args) => api().convertBuffer(...args); -/** - * See {@link TailorIconvAPI.decode}. - * @param args - Forwarded to {@link TailorIconvAPI.decode} - * @returns Decoded UTF-8 string - */ -export const decode: TailorIconvAPI["decode"] = (...args) => api().decode(...args); +const decode: TailorIconvAPI["decode"] = (...args) => api().decode(...args); -/** - * See {@link TailorIconvAPI.encode}. - * @param args - Forwarded to {@link TailorIconvAPI.encode} - * @returns `string` when `encoding` is `"UTF8"` or `"UTF-8"`, otherwise `Uint8Array`. - */ -export const encode: TailorIconvAPI["encode"] = (...args) => api().encode(...args); +const encode: TailorIconvAPI["encode"] = (...args) => api().encode(...args); -/** - * See {@link TailorIconvAPI.encodings}. - * @returns Array of encoding names supported by the platform iconv runtime - */ -export const encodings: TailorIconvAPI["encodings"] = () => api().encodings(); +const encodings: TailorIconvAPI["encodings"] = () => api().encodings(); /** * Stateful converter for repeated conversions between a fixed encoding pair. * Compatible with the `node-iconv` API surface. */ -export class Iconv { +class Iconv { private impl: IconvInstance; constructor(fromEncoding: string, toEncoding: string) { @@ -151,3 +121,14 @@ export class Iconv { return this.impl.convert(input); } } + +// Keep the object typed to the public API so the private wrapper class does not leak into d.ts. +/** Runtime API for `tailor.iconv`. */ +export const iconv: TailorIconvAPI = { + convert, + convertBuffer, + decode, + encode, + encodings, + Iconv, +}; diff --git a/packages/sdk/src/runtime/idp.test.ts b/packages/sdk/src/runtime/idp.test.ts index 0c32ccdaa..5f01e412c 100644 --- a/packages/sdk/src/runtime/idp.test.ts +++ b/packages/sdk/src/runtime/idp.test.ts @@ -5,7 +5,7 @@ * `tailor.idp.Client` and records calls with method, args, and namespace. */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import * as idp from "#/runtime/idp"; +import { idp } from "#/runtime/idp"; import { cleanupMocks, mockIdp, injectMocks } from "#/vitest/mock"; describe("@tailor-platform/sdk/runtime/idp", () => { diff --git a/packages/sdk/src/runtime/idp.ts b/packages/sdk/src/runtime/idp.ts index c707de793..9724dcd44 100644 --- a/packages/sdk/src/runtime/idp.ts +++ b/packages/sdk/src/runtime/idp.ts @@ -11,7 +11,7 @@ * const { users } = await client.users({ first: 10 }); */ -/** Configuration object for {@link Client}. */ +/** Configuration object for `idp.Client`. */ export interface ClientConfig { namespace: string; } @@ -29,12 +29,12 @@ export interface User { mfaEnrolled: boolean; /** * Enrolled MFA second factor IDs. Pass an entry into - * {@link Client.unenrollMfa} to remove that factor. + * `idp.Client.unenrollMfa()` to remove that factor. */ mfaFactorIds: string[]; } -/** Filter options for {@link Client.users}. */ +/** Filter options for `idp.Client.users()`. */ export interface UserQuery { /** Filter by user IDs */ ids?: string[]; @@ -42,7 +42,7 @@ export interface UserQuery { names?: string[]; } -/** Pagination/filter options for {@link Client.users}. */ +/** Pagination/filter options for `idp.Client.users()`. */ export interface ListUsersOptions { /** Maximum number of users to return */ first?: number; @@ -52,14 +52,14 @@ export interface ListUsersOptions { query?: UserQuery; } -/** Response shape for {@link Client.users}. */ +/** Response shape for `idp.Client.users()`. */ export interface ListUsersResponse { users: User[]; nextPageToken: string | null; totalCount: number; } -/** Input for {@link Client.createUser}. */ +/** Input for `idp.Client.createUser()`. */ export interface CreateUserInput { /** The user's name (typically email) */ name: string; @@ -69,7 +69,7 @@ export interface CreateUserInput { disabled?: boolean; } -/** Input for {@link Client.updateUser}. */ +/** Input for `idp.Client.updateUser()`. */ export interface UpdateUserInput { /** The user's ID */ id: string; @@ -83,7 +83,7 @@ export interface UpdateUserInput { disabled?: boolean; } -/** Input for {@link Client.sendPasswordResetEmail}. */ +/** Input for `idp.Client.sendPasswordResetEmail()`. */ export interface SendPasswordResetEmailInput { /** The ID of the user */ userId: string; @@ -95,7 +95,7 @@ export interface SendPasswordResetEmailInput { subject?: string; } -/** Input for {@link Client.unenrollMfa}. */ +/** Input for `idp.Client.unenrollMfa()`. */ export interface UnenrollMfaInput { /** The ID of the user whose factor will be unenrolled. */ userId: string; @@ -140,11 +140,13 @@ export interface TailorIdpAPI { * * Wraps the platform-provided `tailor.idp.Client` and exposes the same surface. */ -export class Client { +class Client { #impl: IdpClientInstance; constructor(config: ClientConfig) { - this.#impl = new (globalThis as { tailor: { idp: TailorIdpAPI } }).tailor.idp.Client(config); + this.#impl = new (globalThis as unknown as { tailor: { idp: TailorIdpAPI } }).tailor.idp.Client( + config, + ); } /** @@ -219,3 +221,7 @@ export class Client { return this.#impl.unenrollMfa(input); } } + +// Keep the object typed to the public API so the private wrapper class does not leak into d.ts. +/** Runtime API for `tailor.idp`. */ +export const idp: TailorIdpAPI = { Client }; diff --git a/packages/sdk/src/runtime/index.test.ts b/packages/sdk/src/runtime/index.test.ts new file mode 100644 index 000000000..42a2ce6a0 --- /dev/null +++ b/packages/sdk/src/runtime/index.test.ts @@ -0,0 +1,100 @@ +/** + * Tests for the aggregate `@tailor-platform/sdk/runtime` entry point. + */ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import * as path from "node:path"; +import ts from "typescript"; +import { describe, expect, expectTypeOf, test } from "vitest"; +import * as aigatewayModule from "#/runtime/aigateway"; +import * as authconnectionModule from "#/runtime/authconnection"; +import * as contextModule from "#/runtime/context"; +import * as fileModule from "#/runtime/file"; +import * as iconvModule from "#/runtime/iconv"; +import * as idpModule from "#/runtime/idp"; +import { file, type iconv as runtimeIconv, type idp as runtimeIdp } from "#/runtime/index"; +import * as secretmanagerModule from "#/runtime/secretmanager"; +import * as workflowModule from "#/runtime/workflow"; +import type { IconvInstance } from "#/runtime/iconv"; +import type { IdpClientInstance } from "#/runtime/idp"; + +const packageRoot = path.resolve(import.meta.dirname, "../.."); + +function declarationEmitDiagnostics(source: string): string { + const tmpDir = mkdtempSync(path.join(packageRoot, ".tmp-runtime-declaration-")); + try { + const tsconfigPath = path.join(tmpDir, "tsconfig.json"); + writeFileSync(path.join(tmpDir, "index.ts"), source, "utf8"); + writeFileSync( + tsconfigPath, + JSON.stringify({ + extends: "../tsconfig.json", + compilerOptions: { + declaration: true, + emitDeclarationOnly: true, + noEmit: false, + incremental: false, + outDir: "dist", + }, + include: ["index.ts"], + }), + "utf8", + ); + + const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (configFile.error) { + return ts.formatDiagnosticsWithColorAndContext([configFile.error], diagnosticHost(tmpDir)); + } + + const config = ts.parseJsonConfigFileContent(configFile.config, ts.sys, tmpDir); + const program = ts.createProgram(config.fileNames, config.options); + const emit = program.emit(); + const diagnostics = [...ts.getPreEmitDiagnostics(program), ...emit.diagnostics]; + return ts.formatDiagnosticsWithColorAndContext(diagnostics, diagnosticHost(tmpDir)); + } finally { + rmSync(tmpDir, { force: true, recursive: true }); + } +} + +function diagnosticHost(cwd: string): ts.FormatDiagnosticsHost { + return { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => cwd, + getNewLine: () => "\n", + }; +} + +describe("@tailor-platform/sdk/runtime aggregate exports", () => { + test.each([ + ["aigateway", aigatewayModule], + ["authconnection", authconnectionModule], + ["context", contextModule], + ["file", fileModule], + ["iconv", iconvModule], + ["idp", idpModule], + ["secretmanager", secretmanagerModule], + ["workflow", workflowModule], + ])("%s subpath has no default export", (_name, runtimeModule) => { + expect(runtimeModule).not.toHaveProperty("default"); + }); + + test("exposes constructor instance types through namespace object values", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test("emits declarations for exported namespace constructor instances", () => { + const diagnostics = declarationEmitDiagnostics(` + import { idp } from "#/runtime/idp"; + import { iconv } from "#/runtime/iconv"; + + export const makeClient = () => new idp.Client({ namespace: "default" }); + export const makeConverter = () => new iconv.Iconv("UTF-8", "Shift_JIS"); + `); + + expect(diagnostics).toBe(""); + }); + + test("does not expose the removed file.deleteFile alias", () => { + expect("deleteFile" in file).toBe(false); + }); +}); diff --git a/packages/sdk/src/runtime/index.ts b/packages/sdk/src/runtime/index.ts index f8d6f2602..366b579af 100644 --- a/packages/sdk/src/runtime/index.ts +++ b/packages/sdk/src/runtime/index.ts @@ -17,21 +17,21 @@ import type { TailorAigatewayAPI } from "./aigateway"; import type { TailorAuthconnectionAPI } from "./authconnection"; -import type { TailorContextAPI } from "./context"; +import type { PlatformContextAPI } from "./context"; import type { TailorDBFileAPI } from "./file"; import type { TailorIconvAPI } from "./iconv"; import type { TailorIdpAPI } from "./idp"; import type { TailorSecretmanagerAPI } from "./secretmanager"; -import type { TailorWorkflowAPI } from "./workflow"; +import type { PlatformWorkflowAPI } from "./workflow"; -export * as iconv from "./iconv"; -export * as secretmanager from "./secretmanager"; -export * as authconnection from "./authconnection"; -export * as idp from "./idp"; -export * as workflow from "./workflow"; -export * as context from "./context"; -export * as file from "./file"; -export * as aigateway from "./aigateway"; +export { iconv } from "./iconv"; +export { secretmanager } from "./secretmanager"; +export { authconnection } from "./authconnection"; +export { idp } from "./idp"; +export { workflow } from "./workflow"; +export { context } from "./context"; +export { file } from "./file"; +export { aigateway } from "./aigateway"; /** SQL command type recorded on a {@link TailordbQueryResult}. */ export type TailordbCommandType = @@ -69,8 +69,8 @@ export interface TailorRuntime { authconnection: TailorAuthconnectionAPI; iconv: TailorIconvAPI; idp: TailorIdpAPI; - workflow: TailorWorkflowAPI; - context: TailorContextAPI; + workflow: PlatformWorkflowAPI; + context: PlatformContextAPI; aigateway: TailorAigatewayAPI; } diff --git a/packages/sdk/src/runtime/secretmanager.test.ts b/packages/sdk/src/runtime/secretmanager.test.ts index 40edcf7e1..aa6716727 100644 --- a/packages/sdk/src/runtime/secretmanager.test.ts +++ b/packages/sdk/src/runtime/secretmanager.test.ts @@ -2,7 +2,7 @@ * Tests for `@tailor-platform/sdk/runtime/secretmanager` typed wrappers. */ import { afterEach, beforeEach, describe, expect, expectTypeOf, test } from "vitest"; -import * as secretmanager from "#/runtime/secretmanager"; +import { secretmanager } from "#/runtime/secretmanager"; import { cleanupMocks, injectMocks, mockSecretmanager } from "#/vitest/mock"; describe("@tailor-platform/sdk/runtime/secretmanager", () => { diff --git a/packages/sdk/src/runtime/secretmanager.ts b/packages/sdk/src/runtime/secretmanager.ts index 82ceb1744..64c9f96dd 100644 --- a/packages/sdk/src/runtime/secretmanager.ts +++ b/packages/sdk/src/runtime/secretmanager.ts @@ -15,10 +15,6 @@ /** * Platform API surface for `tailor.secretmanager`. Describes the shape the * platform runtime injects on `globalThis.tailor.secretmanager`. - * - * Each method below is also re-exported as a top-level named export from this - * module so callers can either `import * as secretmanager from - * "@tailor-platform/sdk/runtime/secretmanager"` or pick individual methods. */ export interface TailorSecretmanagerAPI { /** @@ -42,19 +38,15 @@ export interface TailorSecretmanagerAPI { } const api = (): TailorSecretmanagerAPI => - (globalThis as { tailor: { secretmanager: TailorSecretmanagerAPI } }).tailor.secretmanager; + (globalThis as unknown as { tailor: { secretmanager: TailorSecretmanagerAPI } }).tailor + .secretmanager; -/** - * See {@link TailorSecretmanagerAPI.getSecrets}. - * @param args - Forwarded to {@link TailorSecretmanagerAPI.getSecrets} - * @returns Partial record keyed by the requested names - */ -export const getSecrets: TailorSecretmanagerAPI["getSecrets"] = (...args) => - api().getSecrets(...args); +const getSecrets: TailorSecretmanagerAPI["getSecrets"] = (...args) => api().getSecrets(...args); -/** - * See {@link TailorSecretmanagerAPI.getSecret}. - * @param args - Forwarded to {@link TailorSecretmanagerAPI.getSecret} - * @returns The secret value, or `undefined` if not present - */ -export const getSecret: TailorSecretmanagerAPI["getSecret"] = (...args) => api().getSecret(...args); +const getSecret: TailorSecretmanagerAPI["getSecret"] = (...args) => api().getSecret(...args); + +/** Runtime wrapper namespace for `tailor.secretmanager`. */ +export const secretmanager = { + getSecrets, + getSecret, +} as const satisfies TailorSecretmanagerAPI; diff --git a/packages/sdk/src/runtime/types.ts b/packages/sdk/src/runtime/types.ts index 99b928e00..8aa961e2e 100644 --- a/packages/sdk/src/runtime/types.ts +++ b/packages/sdk/src/runtime/types.ts @@ -5,93 +5,33 @@ // without pulling any runtime dependency. // Interfaces for module augmentation -// Users can extend these via: declare module "@tailor-platform/sdk" { interface AttributeMap { ... } } +// Users can extend these via: declare module "@tailor-platform/sdk" { interface Attributes { ... } } // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface AttributeMap {} +export interface Attributes {} export interface AttributeList { __tuple?: []; // Marker for tuple type } -export type InferredAttributeMap = keyof AttributeMap extends never +export type InferredAttributes = keyof Attributes extends never ? Record - : AttributeMap; + : Attributes; export type InferredAttributeList = AttributeList["__tuple"] extends [] ? string[] : AttributeList["__tuple"]; -/** Represents a user in the Tailor platform. */ -export type TailorUser = { - /** - * The ID of the user. - * For unauthenticated users, this will be a nil UUID. - */ +/** Represents a user or machine user principal in the Tailor Platform. */ +export type TailorPrincipal = { + /** The ID of the principal. */ id: string; - /** - * The type of the user. - * For unauthenticated users, this will be an empty string. - */ - type: "user" | "machine_user" | ""; - /** The ID of the workspace the user belongs to. */ - workspaceId: string; - /** - * A map of the user's attributes. - * For unauthenticated users, this will be null. - */ - attributes: InferredAttributeMap | null; - /** - * A list of the user's attributes. - * For unauthenticated users, this will be an empty array. - */ - attributeList: InferredAttributeList; -}; - -/** - * The invoker of the current function execution. - * - * Reflects `authInvoker` delegation: when `authInvoker` is specified, this is - * the machine user; otherwise it is the calling user. - * Distinct from resolver's `user` (the authenticated caller) and executor's - * `actor` (the subject of the event). - * - * `null` for anonymous requests. - * - * TODO(v2): unify with `TailorUser` — same underlying principal shape. - */ -export type TailorInvoker = { - /** The ID of the invoker (user ID or machine user ID). */ - id: string; - /** The type of the invoker. */ + /** The type of the principal. */ type: "user" | "machine_user"; - /** The ID of the workspace the invoker belongs to. */ - workspaceId: string; - /** A map of the invoker's attributes. */ - attributes: InferredAttributeMap; - /** A list of the invoker's attribute IDs. */ - attributeList: InferredAttributeList; -} | null; - -/** User type enum values from the Tailor Platform server. */ -export type TailorActorType = "USER_TYPE_USER" | "USER_TYPE_MACHINE_USER" | "USER_TYPE_UNSPECIFIED"; - -/** Represents an actor in event triggers. */ -export type TailorActor = { - /** The ID of the workspace the user belongs to. */ + /** The ID of the workspace the principal belongs to. */ workspaceId: string; - /** The ID of the user. */ - userId: string; - /** - * A map of the user's attributes. - * Maps from server's `attributeMap` field. - */ - attributes: InferredAttributeMap | null; - /** - * A list of the user's attributes. - * Maps from server's `attributes` field. - */ + /** A map of the principal's attributes. */ + attributes: InferredAttributes; + /** A list of the principal's attribute IDs. */ attributeList: InferredAttributeList; - /** The type of the user. */ - userType: TailorActorType; }; // Interface for module augmentation diff --git a/packages/sdk/src/runtime/workflow.test.ts b/packages/sdk/src/runtime/workflow.test.ts index e6ec418ae..824ffc973 100644 --- a/packages/sdk/src/runtime/workflow.test.ts +++ b/packages/sdk/src/runtime/workflow.test.ts @@ -2,9 +2,8 @@ * Tests for `@tailor-platform/sdk/runtime/workflow` typed wrappers. */ import { afterEach, beforeEach, describe, expect, expectTypeOf, test } from "vitest"; -import * as workflow from "#/runtime/workflow"; +import { workflow, type ExecutionPolicyKey, type PlatformWorkflowAPI } from "#/runtime/workflow"; import { cleanupMocks, injectMocks, mockWorkflow } from "#/vitest/mock"; -import type { ExecutionPolicyKey } from "#/runtime/workflow"; describe("@tailor-platform/sdk/runtime/workflow", () => { beforeEach(() => { @@ -15,20 +14,24 @@ describe("@tailor-platform/sdk/runtime/workflow", () => { cleanupMocks(globalThis); }); - test("triggerWorkflow forwards args and returns Promise", async () => { + test("exposes the platform workflow API", () => { + expectTypeOf(workflow).toExtend(); + }); + + test("startWorkflow forwards args and returns Promise", async () => { using wf = mockWorkflow(); - wf.setTriggerHandler("exec-42"); + wf.setStartHandler("exec-42"); - const promise = workflow.triggerWorkflow("my-workflow", { a: 1 }); + const promise = workflow.startWorkflow("my-workflow", { a: 1 }); expectTypeOf(promise).toEqualTypeOf>(); await expect(promise).resolves.toBe("exec-42"); - expect(wf.triggerWorkflow.mock.calls).toEqual([["my-workflow", { a: 1 }]]); + expect(wf.startWorkflow.mock.calls).toEqual([["my-workflow", { a: 1 }]]); }); - test("triggerWorkflow forwards options", async () => { + test("startWorkflow forwards options", async () => { using wf = mockWorkflow(); - await workflow.triggerWorkflow( + await workflow.startWorkflow( "my-workflow", { a: 1 }, { @@ -36,43 +39,43 @@ describe("@tailor-platform/sdk/runtime/workflow", () => { }, ); - expect(wf.triggerWorkflow.mock.calls[0]?.[2]).toEqual({ + expect(wf.startWorkflow.mock.calls[0]?.[2]).toEqual({ authInvoker: { namespace: "ns", machineUserName: "mu" }, }); }); - test("resumeWorkflow forwards executionId and returns Promise", async () => { + test("resumeWorkflowExecution forwards executionId and returns Promise", async () => { using wf = mockWorkflow(); wf.setResumeHandler("exec-resumed"); - const promise = workflow.resumeWorkflow("exec-1"); + const promise = workflow.resumeWorkflowExecution("exec-1"); expectTypeOf(promise).toEqualTypeOf>(); await expect(promise).resolves.toBe("exec-resumed"); - expect(wf.resumeWorkflow.mock.calls).toEqual([["exec-1"]]); + expect(wf.resumeWorkflowExecution.mock.calls).toEqual([["exec-1"]]); }); - test("triggerJobFunction forwards and returns enqueued result", () => { + test("startJobFunction forwards and returns enqueued result", () => { using wf = mockWorkflow(); wf.enqueueResult({ ok: true }); - const result = workflow.triggerJobFunction("my-job", { id: 1 }); + const result = workflow.startJobFunction("my-job", { id: 1 }); expect(result).toEqual({ ok: true }); - expect(wf.triggeredJobs).toEqual([{ jobName: "my-job", args: { id: 1 } }]); + expect(wf.startedJobs).toEqual([{ jobName: "my-job", args: { id: 1 } }]); }); - test("triggerJobFunction forwards executionPolicyKey option", () => { + test("startJobFunction forwards executionPolicyKey option", () => { using wf = mockWorkflow(); wf.enqueueResult({ ok: true }); const policyKey = "premium" as ExecutionPolicyKey; - workflow.triggerJobFunction("my-job", { id: 1 }, { executionPolicyKey: policyKey }); + workflow.startJobFunction("my-job", { id: 1 }, { executionPolicyKey: policyKey }); - expect(wf.triggeredJobs).toEqual([ + expect(wf.startedJobs).toEqual([ { jobName: "my-job", args: { id: 1 }, options: { executionPolicyKey: "premium" } }, ]); - expect(wf.triggerJobFunction.mock.calls[0]?.[2]).toEqual({ executionPolicyKey: "premium" }); + expect(wf.startJobFunction.mock.calls[0]?.[2]).toEqual({ executionPolicyKey: "premium" }); }); test("wait records the call and returns the configured result", () => { @@ -96,54 +99,4 @@ describe("@tailor-platform/sdk/runtime/workflow", () => { expect(wf.resolve).toHaveBeenCalledTimes(1); expect(wf.resolveCalls).toEqual([{ executionId: "exec-1", key: "key-1" }]); }); - - describe("canonical aliases", () => { - test("startWorkflow behaves as an alias of triggerWorkflow", async () => { - using wf = mockWorkflow(); - wf.setTriggerHandler("exec-canonical"); - - const promise = workflow.startWorkflow("my-workflow", { a: 1 }); - - expectTypeOf(promise).toEqualTypeOf>(); - await expect(promise).resolves.toBe("exec-canonical"); - expect(wf.startWorkflow.mock.calls).toEqual([["my-workflow", { a: 1 }]]); - expect(wf.startWorkflow).toBe(wf.triggerWorkflow); - }); - - test("resumeWorkflowExecution behaves as an alias of resumeWorkflow", async () => { - using wf = mockWorkflow(); - wf.setResumeHandler("exec-canonical-resumed"); - - const promise = workflow.resumeWorkflowExecution("exec-1"); - - expectTypeOf(promise).toEqualTypeOf>(); - await expect(promise).resolves.toBe("exec-canonical-resumed"); - expect(wf.resumeWorkflowExecution.mock.calls).toEqual([["exec-1"]]); - expect(wf.resumeWorkflowExecution).toBe(wf.resumeWorkflow); - }); - - test("startJobFunction behaves as an alias of triggerJobFunction", () => { - using wf = mockWorkflow(); - wf.enqueueResult({ canonical: true }); - - const result = workflow.startJobFunction("my-job", { id: 1 }); - - expect(result).toEqual({ canonical: true }); - expect(wf.startJobFunction.mock.calls).toEqual([["my-job", { id: 1 }]]); - expect(wf.startJobFunction).toBe(wf.triggerJobFunction); - }); - - test("calls through canonical and legacy names share the same call log", () => { - using wf = mockWorkflow(); - wf.setJobHandler(() => ({ ok: true })); - - workflow.startJobFunction("job-a", { via: "canonical" }); - workflow.triggerJobFunction("job-b", { via: "legacy" }); - - expect(wf.triggeredJobs).toEqual([ - { jobName: "job-a", args: { via: "canonical" } }, - { jobName: "job-b", args: { via: "legacy" } }, - ]); - }); - }); }); diff --git a/packages/sdk/src/runtime/workflow.ts b/packages/sdk/src/runtime/workflow.ts index d6f2129d2..e87fdfd14 100644 --- a/packages/sdk/src/runtime/workflow.ts +++ b/packages/sdk/src/runtime/workflow.ts @@ -4,12 +4,6 @@ * Thin typed wrapper around the platform-provided `tailor.workflow` runtime API. * At runtime this delegates to `globalThis.tailor.workflow`. Use `mockWorkflow` * from `@tailor-platform/sdk/vitest` to mock these calls in unit tests. - * - * The canonical names (`startWorkflow`, `startJobFunction`, - * `resumeWorkflowExecution`) mirror the public `tailor.v1` RPC vocabulary. - * The pre-alignment names (`triggerWorkflow`, `triggerJobFunction`, - * `resumeWorkflow`) are kept as frozen aliases that reference the same platform - * implementations, so existing code continues to work unchanged. * @example * import { workflow } from "@tailor-platform/sdk/runtime"; * @@ -22,7 +16,7 @@ * Specifies the machine user that should be used to execute the workflow. * This allows workflows to run with specific authentication context. */ -export interface AuthInvoker { +export interface Invoker { /** The namespace where the machine user is defined */ namespace: string; /** The name of the machine user to use for workflow execution */ @@ -32,15 +26,9 @@ export interface AuthInvoker { /** Options for {@link startWorkflow}. */ export interface StartWorkflowOptions { /** Optional authentication invoker to specify which machine user should execute the workflow */ - authInvoker?: AuthInvoker; + authInvoker?: Invoker; } -/** - * Frozen alias for {@link StartWorkflowOptions}. Kept for backward compatibility. - * @deprecated Use {@link StartWorkflowOptions} instead. - */ -export type TriggerWorkflowOptions = StartWorkflowOptions; - declare const executionPolicyKeyBrand: unique symbol; /** @@ -60,27 +48,13 @@ export interface StartJobFunctionOptions { executionPolicyKey?: ExecutionPolicyKey; } -/** - * Frozen alias for {@link StartJobFunctionOptions}. Kept for backward compatibility. - * @deprecated Use {@link StartJobFunctionOptions} instead. - */ -export type TriggerJobFunctionOptions = StartJobFunctionOptions; - /** * Platform API surface for `tailor.workflow`. Describes the shape the platform * runtime injects on `globalThis.tailor.workflow`. - * - * Each method below is also re-exported as a top-level named export from this - * module so callers can either `import * as workflow from - * "@tailor-platform/sdk/runtime/workflow"` or pick individual methods. */ -export interface TailorWorkflowAPI { +export interface PlatformWorkflowAPI { /** * Starts a workflow and returns its execution ID. - * - * Canonical name that mirrors the `tailor.v1` RPC vocabulary. - * {@link triggerWorkflow} is a frozen alias that resolves to the same - * platform implementation. * @param workflowName - Workflow name as defined in tailor.config * @param args - Arguments forwarded to the workflow's main job * @param options - Optional start options (e.g. `authInvoker`) @@ -88,39 +62,15 @@ export interface TailorWorkflowAPI { */ startWorkflow(workflowName: string, args?: any, options?: StartWorkflowOptions): Promise; - /** - * Frozen alias for {@link startWorkflow}. Kept for backward compatibility. - * @deprecated Use {@link startWorkflow} instead. - */ - triggerWorkflow( - workflowName: string, - args?: any, - options?: TriggerWorkflowOptions, - ): Promise; - /** * Resumes a failed or pending-retry workflow execution and returns its execution ID. - * - * Canonical name that mirrors the `tailor.v1` RPC vocabulary. - * {@link resumeWorkflow} is a frozen alias that resolves to the same - * platform implementation. * @param executionId - The execution to resume * @returns The execution ID of the resumed workflow */ resumeWorkflowExecution(executionId: string): Promise; - /** - * Frozen alias for {@link resumeWorkflowExecution}. Kept for backward compatibility. - * @deprecated Use {@link resumeWorkflowExecution} instead. - */ - resumeWorkflow(executionId: string): Promise; - /** * Starts a job function and returns its result. - * - * Canonical name that mirrors the `tailor.v1` RPC vocabulary. - * {@link triggerJobFunction} is a frozen alias that resolves to the same - * platform implementation. * @param jobName - Job name as defined in the workflow * @param args - Arguments forwarded to the job * @param options - Optional start options (e.g. `executionPolicyKey`) @@ -128,12 +78,6 @@ export interface TailorWorkflowAPI { */ startJobFunction(jobName: string, args?: any, options?: StartJobFunctionOptions): any; - /** - * Frozen alias for {@link startJobFunction}. Kept for backward compatibility. - * @deprecated Use {@link startJobFunction} instead. - */ - triggerJobFunction(jobName: string, args?: any, options?: TriggerJobFunctionOptions): any; - /** * Suspends the current workflow execution and waits for an external signal to resume. * @param key - Wait point key @@ -152,70 +96,42 @@ export interface TailorWorkflowAPI { resolve(executionId: string, key: string, callback: (waitPayload: any) => any): Promise; } -const api = (): TailorWorkflowAPI => - (globalThis as { tailor: { workflow: TailorWorkflowAPI } }).tailor.workflow; +const api = (): PlatformWorkflowAPI => + (globalThis as unknown as { tailor: { workflow: PlatformWorkflowAPI } }).tailor.workflow; /** - * See {@link TailorWorkflowAPI.startWorkflow}. - * @param args - Forwarded to {@link TailorWorkflowAPI.startWorkflow} + * See {@link PlatformWorkflowAPI.startWorkflow}. + * @param args - Forwarded to {@link PlatformWorkflowAPI.startWorkflow} * @returns The execution ID of the started workflow */ -export const startWorkflow: TailorWorkflowAPI["startWorkflow"] = (...args) => +const startWorkflow: PlatformWorkflowAPI["startWorkflow"] = (...args) => api().startWorkflow(...args); /** - * Frozen alias for {@link startWorkflow}. Kept for backward compatibility. - * @deprecated Use {@link startWorkflow} instead. - * @param args - Forwarded to {@link TailorWorkflowAPI.triggerWorkflow} - * @returns The execution ID of the triggered workflow - */ -export const triggerWorkflow: TailorWorkflowAPI["triggerWorkflow"] = (...args) => - api().triggerWorkflow(...args); - -/** - * See {@link TailorWorkflowAPI.resumeWorkflowExecution}. - * @param args - Forwarded to {@link TailorWorkflowAPI.resumeWorkflowExecution} + * See {@link PlatformWorkflowAPI.resumeWorkflowExecution}. + * @param args - Forwarded to {@link PlatformWorkflowAPI.resumeWorkflowExecution} * @returns The execution ID of the resumed workflow */ -export const resumeWorkflowExecution: TailorWorkflowAPI["resumeWorkflowExecution"] = (...args) => +const resumeWorkflowExecution: PlatformWorkflowAPI["resumeWorkflowExecution"] = (...args) => api().resumeWorkflowExecution(...args); /** - * Frozen alias for {@link resumeWorkflowExecution}. Kept for backward compatibility. - * @deprecated Use {@link resumeWorkflowExecution} instead. - * @param args - Forwarded to {@link TailorWorkflowAPI.resumeWorkflow} - * @returns The execution ID of the resumed workflow - */ -export const resumeWorkflow: TailorWorkflowAPI["resumeWorkflow"] = (...args) => - api().resumeWorkflow(...args); - -/** - * See {@link TailorWorkflowAPI.startJobFunction}. - * @param args - Forwarded to {@link TailorWorkflowAPI.startJobFunction} + * See {@link PlatformWorkflowAPI.startJobFunction}. + * @param args - Forwarded to {@link PlatformWorkflowAPI.startJobFunction} * @returns The job's return value */ -export const startJobFunction: TailorWorkflowAPI["startJobFunction"] = (...args) => +const startJobFunction: PlatformWorkflowAPI["startJobFunction"] = (...args) => api().startJobFunction(...args); -/** - * Frozen alias for {@link startJobFunction}. Kept for backward compatibility. - * @deprecated Use {@link startJobFunction} instead. - * @param args - Forwarded to {@link TailorWorkflowAPI.triggerJobFunction} - * @returns The job's return value - */ -export const triggerJobFunction: TailorWorkflowAPI["triggerJobFunction"] = (...args) => - api().triggerJobFunction(...args); +const wait: PlatformWorkflowAPI["wait"] = (...args) => api().wait(...args); -/** - * See {@link TailorWorkflowAPI.wait}. - * @param args - Forwarded to {@link TailorWorkflowAPI.wait} - * @returns The payload supplied by the corresponding `resolve` call - */ -export const wait: TailorWorkflowAPI["wait"] = (...args) => api().wait(...args); +const resolve: PlatformWorkflowAPI["resolve"] = (...args) => api().resolve(...args); -/** - * See {@link TailorWorkflowAPI.resolve}. - * @param args - Forwarded to {@link TailorWorkflowAPI.resolve} - * @returns A promise that resolves once the resolve has been recorded - */ -export const resolve: TailorWorkflowAPI["resolve"] = (...args) => api().resolve(...args); +/** Runtime wrapper namespace for `tailor.workflow`. */ +export const workflow = { + startWorkflow, + resumeWorkflowExecution, + startJobFunction, + wait, + resolve, +} as const satisfies PlatformWorkflowAPI; diff --git a/packages/sdk/src/types/auth-connection.generated.ts b/packages/sdk/src/types/auth-connection.generated.ts index 1b0034829..654eff112 100644 --- a/packages/sdk/src/types/auth-connection.generated.ts +++ b/packages/sdk/src/types/auth-connection.generated.ts @@ -17,12 +17,19 @@ export type AuthConnectionOAuth2Config = { export type AuthConnectionOAuth2ConfigInput = AuthConnectionOAuth2Config; export type AuthConnectionConfig = { - type: "oauth2"; + /** OAuth2 provider URL */ providerUrl: string; + /** OAuth2 issuer URL */ issuerUrl: string; + /** OAuth2 client ID */ clientId: string; + /** OAuth2 client secret */ clientSecret: string; + /** Connection type */ + type: "oauth2"; + /** OAuth2 authorization endpoint override */ authUrl?: string | undefined; + /** OAuth2 token endpoint override */ tokenUrl?: string | undefined; }; export type AuthConnectionConfigInput = AuthConnectionConfig; diff --git a/packages/sdk/src/types/auth.generated.ts b/packages/sdk/src/types/auth.generated.ts index 1e48f4546..e77705b7c 100644 --- a/packages/sdk/src/types/auth.generated.ts +++ b/packages/sdk/src/types/auth.generated.ts @@ -391,11 +391,11 @@ export type AuthConfigInput = connections?: | { [x: string]: { - type: "oauth2"; providerUrl: string; issuerUrl: string; clientId: string; clientSecret: string; + type: "oauth2"; authUrl?: string | undefined; tokenUrl?: string | undefined; }; @@ -405,17 +405,919 @@ export type AuthConfigInput = userProfile?: | { type: { - name: string; - fields: any; - metadata: any; - hooks: any; - validate: any; - features: any; - indexes: any; - files: any; - permission: any; - gqlPermission: any; - _output: any; + readonly name: string; + readonly fields: any; + readonly _output: {}; + readonly metadata: { + name: string; + description?: string | undefined; + settings?: + | { + pluralForm?: string | undefined; + aggregation?: boolean | undefined; + bulkUpsert?: boolean | undefined; + gqlOperations?: + | "query" + | { + create?: boolean | undefined | undefined; + update?: boolean | undefined | undefined; + delete?: boolean | undefined | undefined; + read?: boolean | undefined | undefined; + } + | undefined; + publishEvents?: boolean | undefined; + } + | undefined; + permissions: { + record?: + | { + create: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined | undefined; + permit?: boolean | undefined | undefined; + } + )[]; + read: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined | undefined; + permit?: boolean | undefined | undefined; + } + )[]; + update: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined | undefined; + permit?: boolean | undefined | undefined; + } + )[]; + delete: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined | undefined; + permit?: boolean | undefined | undefined; + } + )[]; + } + | undefined; + gql?: + | readonly { + conditions: readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + ), + ])[]; + actions: + | "all" + | readonly ( + | "bulkUpsert" + | "create" + | "update" + | "delete" + | "read" + | "aggregate" + )[]; + permit?: boolean | undefined | undefined; + description?: string | undefined | undefined; + }[] + | undefined; + }; + files: { + [x: string]: string; + }; + indexes?: + | { + [x: string]: { + fields: string[]; + unique?: boolean | undefined; + }; + } + | undefined; + typeHook?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; + typeValidate?: Function | undefined; + }; + readonly plugins: { + pluginId: string; + config: unknown; + }[]; }; usernameField: string; namespace?: string | undefined; @@ -461,7 +1363,9 @@ export type AuthConfigInput = update?: Function | undefined; } | undefined; + validate?: Function[] | undefined; typeName?: string | undefined; + default?: unknown; }; fields: any; }; @@ -589,11 +1493,11 @@ export type AuthConfigInput = connections?: | { [x: string]: { - type: "oauth2"; providerUrl: string; issuerUrl: string; clientId: string; clientSecret: string; + type: "oauth2"; authUrl?: string | undefined; tokenUrl?: string | undefined; }; @@ -739,11 +1643,11 @@ export type AuthConfig = connections?: | { [x: string]: { - type: "oauth2"; providerUrl: string; issuerUrl: string; clientId: string; clientSecret: string; + type: "oauth2"; authUrl?: string | undefined; tokenUrl?: string | undefined; }; @@ -754,16 +1658,962 @@ export type AuthConfig = | { type: { name: string; - fields: any; - metadata: any; - hooks: any; - validate: any; - features: any; - indexes: any; - files: any; - permission: any; - gqlPermission: any; - _output: any; + fields: { + [x: string]: { + type: string; + fields?: any | undefined; + metadata: { + required?: boolean | undefined | undefined; + array?: boolean | undefined | undefined; + description?: string | undefined | undefined; + typeName?: string | undefined | undefined; + allowedValues?: + | { + value: string; + description?: string | undefined | undefined; + }[] + | undefined; + index?: boolean | undefined | undefined; + unique?: boolean | undefined | undefined; + vector?: boolean | undefined | undefined; + foreignKey?: boolean | undefined | undefined; + foreignKeyType?: string | undefined | undefined; + foreignKeyField?: string | undefined | undefined; + hooks?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; + validate?: Function[] | undefined; + serial?: + | { + start: number; + maxValue?: number | undefined | undefined; + format?: string | undefined | undefined; + } + | undefined; + scale?: number | undefined | undefined; + default?: unknown; + }; + rawRelation?: + | { + type: "1-1" | "n-1" | "keyOnly" | "oneToOne" | "manyToOne" | "N-1"; + toward: { + type: string; + as?: string | undefined | undefined; + key?: string | undefined | undefined; + }; + backward?: string | undefined | undefined; + } + | undefined; + }; + }; + metadata: { + name: string; + permissions: { + record?: + | { + create: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined; + permit?: boolean | undefined; + } + )[]; + read: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined; + permit?: boolean | undefined; + } + )[]; + update: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined; + permit?: boolean | undefined; + } + )[]; + delete: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined; + permit?: boolean | undefined; + } + )[]; + } + | undefined; + gql?: + | readonly { + conditions: readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + ), + ])[]; + actions: + | "all" + | readonly ( + | "bulkUpsert" + | "create" + | "update" + | "delete" + | "read" + | "aggregate" + )[]; + permit?: boolean | undefined; + description?: string | undefined; + }[] + | undefined; + }; + files: { + [x: string]: string; + }; + description?: string | undefined; + settings?: + | { + pluralForm?: string | undefined; + aggregation?: boolean | undefined; + bulkUpsert?: boolean | undefined; + gqlOperations?: + | { + create?: boolean | undefined; + update?: boolean | undefined; + delete?: boolean | undefined; + read?: boolean | undefined; + } + | undefined; + publishEvents?: boolean | undefined; + } + | undefined; + indexes?: + | { + [x: string]: { + fields: string[]; + unique?: boolean | undefined; + }; + } + | undefined; + typeHook?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; + typeValidate?: Function | undefined; + }; }; usernameField: string; namespace?: string | undefined; @@ -809,7 +2659,9 @@ export type AuthConfig = update?: Function | undefined; } | undefined; + validate?: Function[] | undefined; typeName?: string | undefined; + default?: unknown; }; fields: any; }; @@ -947,11 +2799,11 @@ export type AuthConfig = connections?: | { [x: string]: { - type: "oauth2"; providerUrl: string; issuerUrl: string; clientId: string; clientSecret: string; + type: "oauth2"; authUrl?: string | undefined; tokenUrl?: string | undefined; }; diff --git a/packages/sdk/src/types/executor.generated.ts b/packages/sdk/src/types/executor.generated.ts index 5ee8615fd..9214d7674 100644 --- a/packages/sdk/src/types/executor.generated.ts +++ b/packages/sdk/src/types/executor.generated.ts @@ -98,8 +98,8 @@ export type FunctionOperation = { kind: "function" | "jobFunction"; /** Function implementation */ body: Function; - /** Auth invoker for the function execution */ - authInvoker?: + /** Invoker for the function execution */ + invoker?: | string | { namespace: string; @@ -116,8 +116,8 @@ export type GqlOperationInput = { appName?: string | undefined; /** Function to compute GraphQL variables */ variables?: Function | undefined; - /** Auth invoker for the GraphQL execution */ - authInvoker?: + /** Invoker for the GraphQL execution */ + invoker?: | string | { namespace: string; @@ -133,8 +133,8 @@ export type GqlOperation = { appName?: string | undefined; /** Function to compute GraphQL variables */ variables?: Function | undefined; - /** Auth invoker for the GraphQL execution */ - authInvoker?: + /** Invoker for the GraphQL execution */ + invoker?: | string | { namespace: string; @@ -163,25 +163,96 @@ export type WebhookOperation = { }; export type WebhookOperationInput = WebhookOperation; -export type WorkflowOperationInput = unknown; +export type JsonValueInput = + | string + | number + | boolean + | JsonValueInput[] + | { [key: string]: JsonValueInput } + | null; -export type WorkflowOperation = { - kind: "workflow"; - workflowName: string; - args?: - | Function - | { +export type JsonValue = + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue } + | null; + +export type WorkflowInput = string | number | boolean | JsonValue[] | { [key: string]: JsonValue }; +export type WorkflowInputInput = WorkflowInput; + +/** + * Arguments to pass to the workflow + */ +export type WorkflowOperationArgs = WorkflowInput | Function; +export type WorkflowOperationArgsInput = WorkflowOperationArgs; + +export type WorkflowOperationInput = + | { + workflow: { [x: string]: unknown; - } - | undefined; - authInvoker?: - | string - | { - namespace: string; - machineUserName: string; - } - | undefined; -}; + name: string; + }; + kind: "workflow"; + workflowName?: string | undefined; + args?: WorkflowOperationArgs; + invoker?: + | string + | { + namespace: string; + machineUserName: string; + } + | undefined; + } + | { + workflowName: string; + kind: "workflow"; + workflow?: undefined; + args?: WorkflowOperationArgs; + invoker?: + | string + | { + namespace: string; + machineUserName: string; + } + | undefined; + }; + +export type WorkflowOperation = + | { + workflowName: string; + kind: "workflow"; + args?: WorkflowOperationArgs; + invoker?: + | string + | { + namespace: string; + machineUserName: string; + } + | undefined; + } + | { + workflowName: string; + kind: "workflow"; + workflow?: undefined; + args?: WorkflowOperationArgs; + invoker?: + | string + | { + namespace: string; + machineUserName: string; + } + | undefined; + }; + +export type OperationInput = + | FunctionOperation + | GqlOperationInput + | WebhookOperation + | WorkflowOperationInput; + +export type Operation = FunctionOperation | GqlOperation | WebhookOperation | WorkflowOperation; export type ExecutorInput = { /** Executor name */ @@ -189,7 +260,7 @@ export type ExecutorInput = { /** Event trigger configuration */ trigger: TriggerInput; /** Operation to execute when triggered */ - operation: unknown; + operation: OperationInput; /** Executor description */ description?: string | undefined; /** Whether the executor is disabled */ @@ -204,63 +275,7 @@ export type Executor = { /** Event trigger configuration */ trigger: Trigger; /** Operation to execute when triggered */ - operation: - | { - kind: "workflow"; - workflowName: string; - args?: - | Function - | { - [x: string]: unknown; - } - | undefined; - authInvoker?: - | string - | { - namespace: string; - machineUserName: string; - } - | undefined; - } - | { - kind: "function" | "jobFunction"; - body: Function; - authInvoker?: - | string - | { - namespace: string; - machineUserName: string; - } - | undefined; - } - | { - kind: "graphql"; - query: string; - appName?: string | undefined; - variables?: Function | undefined; - authInvoker?: - | string - | { - namespace: string; - machineUserName: string; - } - | undefined; - } - | { - kind: "webhook"; - url: Function; - requestBody?: Function | undefined; - headers?: - | { - [x: string]: - | string - | { - vault: string; - key: string; - }; - } - | undefined; - }; + operation: Operation; /** Executor description */ description?: string | undefined; }; diff --git a/packages/sdk/src/types/field.generated.ts b/packages/sdk/src/types/field.generated.ts index 3653e9734..e51f08d4a 100644 --- a/packages/sdk/src/types/field.generated.ts +++ b/packages/sdk/src/types/field.generated.ts @@ -31,7 +31,9 @@ export type TailorFieldInput = { update?: Function | undefined; } | undefined; + validate?: Function[] | undefined; typeName?: string | undefined; + default?: unknown; }; fields: { [x: string]: TailorFieldInput; @@ -69,7 +71,9 @@ export type TailorField = { update?: Function | undefined; } | undefined; + validate?: Function[] | undefined; typeName?: string | undefined; + default?: unknown; }; fields: { [x: string]: TailorField; diff --git a/packages/sdk/src/types/generator-config.generated.ts b/packages/sdk/src/types/generator-config.generated.ts deleted file mode 100644 index ba3ac8cca..000000000 --- a/packages/sdk/src/types/generator-config.generated.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Generated by zinfer - Do not edit manually - -export type CodeGenerator = { - id: string; - description: string; - dependencies: ("tailordb" | "resolver" | "executor")[]; - aggregate: Function; - processType?: Function | undefined; - processResolver?: Function | undefined; - processExecutor?: Function | undefined; - processTailorDBNamespace?: Function | undefined; - processResolverNamespace?: Function | undefined; -}; -export type CodeGeneratorInput = CodeGenerator; - -export type BaseGeneratorConfig = - | ["@tailor-platform/kysely-type", { distPath: string }] - | [ - "@tailor-platform/seed", - { - distPath: string; - machineUserName?: string | undefined; - disableIdpUserSync?: - | { userToIdp?: boolean | undefined; idpToUser?: boolean | undefined } - | undefined; - }, - ] - | ["@tailor-platform/enum-constants", { distPath: string }] - | ["@tailor-platform/file-utils", { distPath: string }] - | { - id: string; - description: string; - dependencies: ("tailordb" | "resolver" | "executor")[]; - aggregate: Function; - processType?: Function | undefined; - processResolver?: Function | undefined; - processExecutor?: Function | undefined; - processTailorDBNamespace?: Function | undefined; - processResolverNamespace?: Function | undefined; - }; -export type BaseGeneratorConfigInput = BaseGeneratorConfig; diff --git a/packages/sdk/src/types/helpers.ts b/packages/sdk/src/types/helpers.ts index a18b35d2a..be3ec5b6b 100644 --- a/packages/sdk/src/types/helpers.ts +++ b/packages/sdk/src/types/helpers.ts @@ -13,6 +13,15 @@ export type DeepWritable = T extends Date | RegExp | Function ? { -readonly [P in keyof T]: DeepWritable } & {} : T; +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export type DeepReadonly = T extends Date | RegExp | Function + ? T + : T extends readonly (infer E)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T; + type LiteralToString = T extends string ? string : T; type SpecificNumberToNumber = T extends number ? number : T; type TrueFalseToBool = T extends number ? number : T; diff --git a/packages/sdk/src/types/resolver.generated.ts b/packages/sdk/src/types/resolver.generated.ts index 923105548..ca99e5774 100644 --- a/packages/sdk/src/types/resolver.generated.ts +++ b/packages/sdk/src/types/resolver.generated.ts @@ -45,7 +45,9 @@ export type Resolver = { update?: Function | undefined; } | undefined; + validate?: Function[] | undefined; typeName?: string | undefined; + default?: unknown; }; fields: { [x: string]: any; @@ -62,7 +64,7 @@ export type Resolver = { /** Enable publishing events from this resolver */ publishEvents?: boolean | undefined; /** Machine user to execute this resolver as */ - authInvoker?: + invoker?: | string | { namespace: string; diff --git a/packages/sdk/src/types/tailordb.generated.ts b/packages/sdk/src/types/tailordb.generated.ts index d1ff55fe4..5be52ca79 100644 --- a/packages/sdk/src/types/tailordb.generated.ts +++ b/packages/sdk/src/types/tailordb.generated.ts @@ -57,7 +57,7 @@ export type DBFieldMetadata = { } | undefined; /** Validation functions for the field */ - validate?: (Function | [Function, string])[] | undefined; + validate?: Function[] | undefined; /** Serial (auto-increment) configuration */ serial?: | { @@ -68,6 +68,8 @@ export type DBFieldMetadata = { | undefined; /** Decimal scale (number of digits after decimal point, 0-12) */ scale?: number | undefined; + /** Default value for the field on create */ + default?: unknown; }; export type DBFieldMetadataInput = DBFieldMetadata; @@ -1014,6 +1016,13 @@ export type TailorDBTypeRawInput = { }; } | undefined; + typeHook?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; + typeValidate?: Function | undefined; }; }; @@ -1046,7 +1055,7 @@ export type TailorDBTypeRaw = { update?: Function | undefined; } | undefined; - validate?: (Function | [Function, string])[] | undefined; + validate?: Function[] | undefined; serial?: | { start: number; @@ -1055,6 +1064,7 @@ export type TailorDBTypeRaw = { } | undefined; scale?: number | undefined | undefined; + default?: unknown; }; rawRelation?: | { @@ -1085,6 +1095,13 @@ export type TailorDBTypeRaw = { }; } | undefined; + typeHook?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; + typeValidate?: Function | undefined; }; }; diff --git a/packages/sdk/src/types/workflow.generated.ts b/packages/sdk/src/types/workflow.generated.ts index 7c571ebb3..aeb6dfe06 100644 --- a/packages/sdk/src/types/workflow.generated.ts +++ b/packages/sdk/src/types/workflow.generated.ts @@ -3,8 +3,8 @@ export type WorkflowJob = { /** Job name (must be unique across the project) */ name: string; - /** Trigger function that initiates the job */ - trigger: Function; + /** Start function that initiates the job */ + start: Function; /** Job implementation function */ body: Function; }; @@ -35,7 +35,7 @@ export type ExecutionPolicyName = string; export type ExecutionPolicyNameInput = ExecutionPolicyName; /** - * Execution policy key passed to startJobFunction's (or its frozen alias triggerJobFunction's) executionPolicyKey option + * Execution policy key passed to startJobFunction's executionPolicyKey option */ export type ExecutionPolicyKey = string; export type ExecutionPolicyKeyInput = ExecutionPolicyKey; @@ -43,7 +43,7 @@ export type ExecutionPolicyKeyInput = ExecutionPolicyKey; export type WorkflowJobFunctionExecutionPolicy = { /** Workspace-unique execution policy name embedded in the resource TRN */ name: ExecutionPolicyName; - /** Execution policy key passed to startJobFunction's (or its frozen alias triggerJobFunction's) executionPolicyKey option */ + /** Execution policy key passed to startJobFunction's executionPolicyKey option */ key: ExecutionPolicyKey; /** Optional per-key concurrency cap for job function dispatches matching this policy */ concurrencyPolicy?: ConcurrencyPolicy; diff --git a/packages/sdk/src/utils/test/index.test.ts b/packages/sdk/src/utils/test/index.test.ts index 9565d0265..7e35ae372 100644 --- a/packages/sdk/src/utils/test/index.test.ts +++ b/packages/sdk/src/utils/test/index.test.ts @@ -8,7 +8,7 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 describe("createTailorDBHook", () => { describe("id field", () => { test("uses existing id from data when provided", () => { - const type = db.type("Test", { name: db.string() }); + const type = db.table("Test", { name: db.string() }); const result = createTailorDBHook(type)({ id: "00000000-0000-0000-0000-000000000001", name: "a", @@ -21,7 +21,7 @@ describe("createTailorDBHook", () => { ["data is null", null], ["data is undefined", undefined], ])("generates a UUID when %s", (_label, data) => { - const type = db.type("Test", { name: db.string() }); + const type = db.table("Test", { name: db.string() }); const result = createTailorDBHook(type)(data); expect(result.id).toMatch(UUID_REGEX); }); @@ -29,7 +29,7 @@ describe("createTailorDBHook", () => { describe("plain field passthrough", () => { test("passes through scalar field values unchanged", () => { - const type = db.type("Test", { + const type = db.table("Test", { name: db.string(), age: db.int(), active: db.bool(), @@ -46,13 +46,13 @@ describe("createTailorDBHook", () => { ["data is null", null], ["data is a non-object primitive", "not-an-object"], ])("does not set scalar fields when %s", (_label, data) => { - const type = db.type("Test", { name: db.string() }); + const type = db.table("Test", { name: db.string() }); const result = createTailorDBHook(type)(data); expect(result.name).toBeUndefined(); }); test("preserves explicit null values from data", () => { - const type = db.type("Test", { nickname: db.string({ optional: true }) }); + const type = db.table("Test", { nickname: db.string({ optional: true }) }); const result = createTailorDBHook(type)({ nickname: null }); expect(result.nickname).toBeNull(); }); @@ -60,7 +60,7 @@ describe("createTailorDBHook", () => { describe("single nested object field", () => { test("recursively processes the nested object", () => { - const type = db.type("Test", { + const type = db.table("Test", { user: db.object({ name: db.string(), age: db.int() }), }); const result = createTailorDBHook(type)({ @@ -70,7 +70,7 @@ describe("createTailorDBHook", () => { }); test("generates a nested id when the nested object has an id field", () => { - const type = db.type("Test", { + const type = db.table("Test", { nested: db.object({ id: db.uuid(), name: db.string() }), }); const result = createTailorDBHook(type)({ nested: { name: "x" } }); @@ -78,10 +78,11 @@ describe("createTailorDBHook", () => { }); test("invokes nested sub-field hooks", () => { - const type = db.type("Test", { + const type = db.table("Test", { user: db.object({ + // @ts-expect-error hooks on nested inner fields are now type-blocked name: db.string().hooks({ - create: ({ value }) => `hooked:${value as string}`, + create: ({ input }) => `hooked:${input as string}`, }), }), }); @@ -92,7 +93,7 @@ describe("createTailorDBHook", () => { describe("nested object array field", () => { test("preserves array values when array is provided", () => { - const type = db.type("Test", { + const type = db.table("Test", { lines: db.object({ kind: db.string(), days: db.int() }, { array: true }), }); const value = [ @@ -104,7 +105,7 @@ describe("createTailorDBHook", () => { }); test("recursively processes each array element so nested ids are generated", () => { - const type = db.type("Test", { + const type = db.table("Test", { lines: db.object({ id: db.uuid(), kind: db.string() }, { array: true }), }); const result = createTailorDBHook(type)({ @@ -118,13 +119,14 @@ describe("createTailorDBHook", () => { test("invokes per-element sub-field hooks", () => { const calls: unknown[] = []; - const type = db.type("Test", { + const type = db.table("Test", { lines: db.object( { + // @ts-expect-error hooks on nested inner fields are now type-blocked stamp: db.string().hooks({ - create: ({ value }) => { - calls.push(value); - return `stamped:${value as string}`; + create: ({ input }) => { + calls.push(input); + return `stamped:${input as string}`; }, }), }, @@ -139,7 +141,7 @@ describe("createTailorDBHook", () => { }); test("preserves an empty array as an empty array", () => { - const type = db.type("Test", { + const type = db.table("Test", { lines: db.object({ kind: db.string() }, { array: true }), }); const result = createTailorDBHook(type)({ lines: [] }); @@ -150,14 +152,14 @@ describe("createTailorDBHook", () => { ["passes through null for optional array field", { lines: null }, null], ["passes through undefined for omitted optional array field", {}, undefined], ])("%s", (_label, data, expected) => { - const type = db.type("Test", { + const type = db.table("Test", { lines: db.object({ kind: db.string() }, { optional: true, array: true }), }); expect(createTailorDBHook(type)(data).lines).toBe(expected); }); test("passes through non-array values without recursing (so the validator surfaces a clear error)", () => { - const type = db.type("Test", { + const type = db.table("Test", { lines: db.object({ kind: db.string() }, { array: true }), }); // Pass an object instead of an array; the hook must not corrupt it into a single @@ -168,49 +170,74 @@ describe("createTailorDBHook", () => { }); }); - describe("create hook on a top-level field", () => { - test("invokes the create hook with value, full data, and the unauthenticated user", () => { - const seen: { value: unknown; data: unknown; userId: string }[] = []; - const type = db.type("Order", { total: db.float(), tax: db.float() }).hooks({ - tax: { - create: ({ value, data, user }) => { - seen.push({ value, data, userId: user.id }); - return (data as { total: number }).total * 0.1; - }, + describe("type-level create hook", () => { + test("invokes the create hook and applies field overrides", () => { + const seen: { input: unknown; invoker: unknown }[] = []; + const type = db.table("Order", { total: db.float(), tax: db.float() }).hooks({ + create: ({ input, invoker }) => { + seen.push({ input, invoker }); + return { tax: (input as { total: number }).total * 0.1 }; }, }); const result = createTailorDBHook(type)({ total: 100, tax: undefined }); expect(result.tax).toBe(10); expect(seen).toEqual([ { - value: undefined, - data: { total: 100, tax: undefined }, - userId: "00000000-0000-0000-0000-000000000000", + input: { total: 100, tax: undefined }, + invoker: null, }, ]); }); - test("normalizes a Date returned from the create hook to an ISO string", () => { + test("normalizes a Date returned from the type hook to an ISO string", () => { const fixed = new Date("2026-04-15T00:00:00.000Z"); const type = db - .type("Test", { createdAt: db.datetime() }) - .hooks({ createdAt: { create: () => fixed } }); + .table("Test", { createdAt: db.datetime() }) + .hooks({ create: () => ({ createdAt: fixed }) }); expect(createTailorDBHook(type)({}).createdAt).toBe("2026-04-15T00:00:00.000Z"); }); + test("shares the same now timestamp between field-level and type-level hooks", () => { + let fieldNow: Date | undefined; + let typeNow: Date | undefined; + const type = db + .table("Test", { + createdAt: db.datetime().hooks({ create: ({ now }) => (fieldNow = now) }), + label: db.string(), + }) + .hooks({ create: ({ now }) => ((typeNow = now), { label: "x" }) }); + createTailorDBHook(type)({}); + expect(fieldNow).toBeInstanceOf(Date); + expect(typeNow).toBe(fieldNow); + }); + + test("runs type-level validate and throws on validation failure", () => { + const type = db + .table("Range", { + start: db.int(), + end: db.int(), + }) + .validate(({ newRecord }, issues) => { + if ((newRecord.start as number) > (newRecord.end as number)) { + issues("start", "start must be <= end"); + } + }); + expect(() => createTailorDBHook(type)({ start: 10, end: 5 })).toThrow( + "Validation failed on field 'start': start must be <= end", + ); + expect(() => createTailorDBHook(type)({ start: 1, end: 10 })).not.toThrow(); + }); + test("does not invoke a hook that only defines update (createTailorDBHook is create-only)", () => { let updateCalled = false; - const type = db.type("Test", { updatedAt: db.datetime() }).hooks({ - updatedAt: { - update: () => { - updateCalled = true; - return new Date(); - }, + const type = db.table("Test", { updatedAt: db.datetime() }).hooks({ + update: () => { + updateCalled = true; + return { updatedAt: new Date() }; }, }); const result = createTailorDBHook(type)({ updatedAt: "2026-01-01T00:00:00.000Z" }); expect(updateCalled).toBe(false); - // Falls through to plain passthrough expect(result.updatedAt).toBe("2026-01-01T00:00:00.000Z"); }); }); @@ -218,7 +245,7 @@ describe("createTailorDBHook", () => { describe("createStandardSchema", () => { const buildSchema = () => { - const type = db.type("PurchaseOrder", { + const type = db.table("PurchaseOrder", { paymentTermSnapshotLines: db.object( { kind: db.string(), days: db.int() }, { optional: true, array: true }, @@ -253,7 +280,7 @@ describe("createStandardSchema", () => { }); test("returns issues when the hooked data fails validation", () => { - const type = db.type("Test", { name: db.string() }); + const type = db.table("Test", { name: db.string() }); const schemaType = t.object({ id: t.uuid(), name: t.string() }); const schema = createStandardSchema(schemaType, createTailorDBHook(type)); diff --git a/packages/sdk/src/utils/test/index.ts b/packages/sdk/src/utils/test/index.ts index 1586e2f22..554e421ad 100644 --- a/packages/sdk/src/utils/test/index.ts +++ b/packages/sdk/src/utils/test/index.ts @@ -1,9 +1,8 @@ -import type { output, TailorUser } from "#/configure/index"; +import type { output } from "#/configure/index"; import type { TailorDBType } from "#/configure/services/tailordb/schema"; import type { TailorField } from "#/configure/types/type"; import type { StandardSchemaV1 } from "@standard-schema/spec"; -export { WORKFLOW_TEST_ENV_KEY } from "#/configure/services/workflow/job"; export { setupTailordbMock, setupTailorErrorsMock, @@ -13,15 +12,6 @@ export { createImportMain, } from "./mock"; -/** Represents an unauthenticated user in the Tailor platform. */ -export const unauthenticatedTailorUser = { - id: "00000000-0000-0000-0000-000000000000", - type: "", - workspaceId: "00000000-0000-0000-0000-000000000000", - attributes: null, - attributeList: [], -} as const satisfies TailorUser; - /** * Creates a hook function that processes TailorDB type fields * - Uses existing id from data if provided, otherwise generates UUID for id fields @@ -33,46 +23,79 @@ export const unauthenticatedTailorUser = { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createTailorDBHook>(type: T) { - return (data: unknown) => { - return Object.entries(type.fields).reduce( + return (data: unknown, now: Date = new Date()) => { + const obj = data && typeof data === "object" ? (data as Record) : undefined; + const hooked = Object.entries(type.fields).reduce( (hooked, [key, value]) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const field = value as TailorField; if (key === "id") { - // Use existing id from data if provided, otherwise generate new UUID - const existingId = - data && typeof data === "object" ? (data as Record)[key] : undefined; - hooked[key] = existingId ?? crypto.randomUUID(); + hooked[key] = obj?.[key] ?? crypto.randomUUID(); } else if (field.type === "nested") { - const nestedValue = - data && typeof data === "object" ? (data as Record)[key] : undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any const nestedHook = createTailorDBHook({ fields: field.fields } as any); if (field.metadata.array) { - // For nested array fields, recurse per element and pass through non-array values - // (e.g. null/undefined for optional fields) so validation sees the original value. + const nestedValue = obj?.[key]; hooked[key] = Array.isArray(nestedValue) - ? nestedValue.map((item) => nestedHook(item)) + ? nestedValue.map((item) => nestedHook(item, now)) : nestedValue; } else { - hooked[key] = nestedHook(nestedValue); + hooked[key] = nestedHook(obj?.[key], now); } } else if (field.metadata.hooks?.create) { hooked[key] = field.metadata.hooks.create({ - value: (data as Record)[key], - data: data, - user: unauthenticatedTailorUser, + input: obj?.[key], + invoker: null, + now, }); if (hooked[key] instanceof Date) { hooked[key] = hooked[key].toISOString(); } - } else if (data && typeof data === "object") { - hooked[key] = (data as Record)[key]; + } else if (obj) { + hooked[key] = obj[key]; + } + if (hooked[key] == null && field.metadata.default !== undefined) { + const isTimeType = + field.type === "datetime" || field.type === "date" || field.type === "time"; + hooked[key] = + field.metadata.default === "now" && isTimeType + ? now.toISOString() + : field.metadata.default; } return hooked; }, {} as Record, - ) as Partial>; + ); + + // oxlint-disable-next-line typescript/no-unnecessary-condition -- metadata absent in recursive nested calls + if (type.metadata?.typeHook?.create) { + const { id: _id, ...typeHookInput } = hooked; + // oxlint-disable-next-line typescript/no-unsafe-function-type + const overrides = (type.metadata.typeHook.create as Function)({ + input: typeHookInput, + invoker: null, + now, + }); + if (overrides && typeof overrides === "object") { + for (const [key, value] of Object.entries(overrides as Record)) { + hooked[key] = value instanceof Date ? value.toISOString() : value; + } + } + } + + // oxlint-disable-next-line typescript/no-unnecessary-condition -- metadata absent in recursive nested calls + if (type.metadata?.typeValidate) { + const { id: _id, ...newRecord } = hooked; + // oxlint-disable-next-line typescript/no-unsafe-function-type + (type.metadata.typeValidate as Function)( + { newRecord, oldRecord: null, invoker: null }, + (field: string, message: string) => { + throw new Error(`Validation failed on field '${field}': ${message}`); + }, + ); + } + + return hooked as Partial>; }; } @@ -98,7 +121,7 @@ export function createStandardSchema>( const result = schemaType.parse({ value: hooked, data: hooked, - user: unauthenticatedTailorUser, + invoker: null, }); if (result.issues) { return result; diff --git a/packages/sdk/src/utils/test/internal.ts b/packages/sdk/src/utils/test/internal.ts index 275a0433b..eb5953f33 100644 --- a/packages/sdk/src/utils/test/internal.ts +++ b/packages/sdk/src/utils/test/internal.ts @@ -1,3 +1,4 @@ +import { stripTailorDBTypeBuilderHelpers } from "#/parser/service/tailordb/builder-helpers"; /** * Internal test utilities for SDK development. * These are NOT exported to library users. @@ -6,16 +7,16 @@ import { TailorDBTypeSchema } from "#/parser/service/tailordb/schema"; import type { TailorDBTypeRaw as TailorDBTypeSchemaOutput } from "#/types/tailordb.generated"; /** - * Converts a single db.type() result to schema-parsed output for testing. + * Converts a single db.table() result to schema-parsed output for testing. * In production, this conversion happens in application loader. - * @param type - The db.type() result to convert + * @param type - The db.table() result to convert * @returns Parsed TailorDB type schema output */ export function toSchemaOutput( - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accept any db.type() result for testing + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accept any db.table() result for testing type: any, ): TailorDBTypeSchemaOutput { - const parsed = TailorDBTypeSchema.safeParse(type); + const parsed = TailorDBTypeSchema.safeParse(stripTailorDBTypeBuilderHelpers(type)); if (!parsed.success) { throw new Error(`Failed to parse type ${type.name}: ${parsed.error.message}`); } @@ -23,13 +24,13 @@ export function toSchemaOutput( } /** - * Converts multiple db.type() results to schema-parsed outputs for testing. + * Converts multiple db.table() results to schema-parsed outputs for testing. * In production, this conversion happens in application loader. - * @param types - Record of db.type() results to convert + * @param types - Record of db.table() results to convert * @returns Record of parsed TailorDB type schema outputs */ export function toSchemaOutputs( - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accept any db.type() result for testing + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accept any db.table() result for testing types: Record, ): Record { const result = Object.create(null) as Record; diff --git a/packages/sdk/src/utils/test/mock.ts b/packages/sdk/src/utils/test/mock.ts index a878faa51..c8f37bee6 100644 --- a/packages/sdk/src/utils/test/mock.ts +++ b/packages/sdk/src/utils/test/mock.ts @@ -1,8 +1,8 @@ import * as path from "node:path"; import { pathToFileURL } from "node:url"; import type { ContextInvoker } from "#/runtime/context"; -import type { TailorInvoker } from "#/runtime/types"; -import type { TriggerJobFunctionOptions } from "#/runtime/workflow"; +import type { TailorPrincipal } from "#/runtime/types"; +import type { StartJobFunctionOptions } from "#/runtime/workflow"; type MainFunction = (args: Record) => unknown | Promise; type QueryResolver = (query: string, params: unknown[]) => unknown[]; @@ -27,10 +27,10 @@ interface TailordbGlobal { }; tailor?: { workflow: { - triggerJobFunction: ( + startJobFunction: ( jobName: string, args: unknown, - options?: TriggerJobFunctionOptions, + options?: StartJobFunctionOptions, ) => unknown; wait?: (key: string, payload?: unknown) => unknown; resolve?: ( @@ -54,7 +54,7 @@ interface TailorErrorsGlobal { TailorErrors?: new (errors: TailorErrorItem[]) => Error; } -const GlobalThis = globalThis as TailordbGlobal & TailorErrorsGlobal; +const GlobalThis = globalThis as unknown as TailordbGlobal & TailorErrorsGlobal; /** * Sets up a mock for `globalThis.tailordb.Client` used in bundled resolver/executor tests. @@ -99,17 +99,17 @@ export function setupTailordbMock(resolver: QueryResolver = () => []): { } /** - * Sets up a mock for `globalThis.tailor.workflow.triggerJobFunction` used in bundled workflow tests. + * Sets up a mock for `globalThis.tailor.workflow.startJobFunction` used in bundled workflow tests. * `wait`/`resolve` are stubbed to throw a helpful error directing to `mockWorkflow`, * so mistakenly calling wait without wait-point mocks produces a clear message instead of a TypeError. * @deprecated Use `mockWorkflow` from `@tailor-platform/sdk/vitest` with the `tailor-runtime` environment instead. - * @param handler - Function that handles triggered job calls and returns results. - * @returns Object containing an array of triggered jobs for assertions. + * @param handler - Function that handles started job calls and returns results. + * @returns Object containing an array of started jobs for assertions. */ export function setupWorkflowMock(handler: JobHandler): { - triggeredJobs: { jobName: string; args: unknown }[]; + startedJobs: { jobName: string; args: unknown }[]; } { - const triggeredJobs: { jobName: string; args: unknown }[] = []; + const startedJobs: { jobName: string; args: unknown }[] = []; GlobalThis.tailor = { ...GlobalThis.tailor, @@ -125,23 +125,23 @@ export function setupWorkflowMock(handler: JobHandler): { ); }, ...GlobalThis.tailor?.workflow, - triggerJobFunction: (jobName: string, args: unknown) => { - triggeredJobs.push({ jobName, args }); + startJobFunction: (jobName: string, args: unknown) => { + startedJobs.push({ jobName, args }); return handler(jobName, args); }, }, } as typeof GlobalThis.tailor; - return { triggeredJobs }; + return { startedJobs }; } /** * Sets up a mock for `globalThis.tailor.context.getInvoker` used in bundled * resolver/executor/workflow tests. * @deprecated With the `tailor-runtime` environment from `@tailor-platform/sdk/vitest`, drive the invoker via `vi.spyOn(globalThis.tailor.context, "getInvoker").mockReturnValue(...)` for bundled tests, or pass `invoker` directly to `.body()` when unit-testing resolvers/executors/workflow jobs against the TypeScript source. - * @param invoker - The `TailorInvoker` value to return, or `null` for anonymous. + * @param invoker - The `TailorPrincipal` value to return, or `null` for anonymous. */ -export function setupInvokerMock(invoker: TailorInvoker): void { +export function setupInvokerMock(invoker: TailorPrincipal | null): void { const raw: ContextInvoker | null = invoker ? { id: invoker.id, @@ -179,8 +179,8 @@ export function setupTailorErrorsMock(): void { /** * Sets up mocks for `globalThis.tailor.workflow.wait` and `.resolve` used in bundled workflow tests. - * `triggerJobFunction` is stubbed to throw a helpful error directing to `setupWorkflowMock()`, - * so mistakenly triggering a job without job mocks produces a clear message instead of silently returning undefined. + * `startJobFunction` is stubbed to throw a helpful error directing to `setupWorkflowMock()`, + * so mistakenly starting a job without job mocks produces a clear message instead of silently returning undefined. * @deprecated Use `mockWorkflow` from `@tailor-platform/sdk/vitest` with the `tailor-runtime` environment instead. * `setWaitHandler` / `setResolveHandler` cover wait/resolve, and `waitCalls` / `resolveCalls` give the same assertion shape. * @param config - Optional handlers for wait and resolve calls. @@ -198,9 +198,9 @@ export function setupWaitPointMock(config?: { onWait?: WaitHandler; onResolve?: GlobalThis.tailor = { ...GlobalThis.tailor, workflow: { - triggerJobFunction: () => { + startJobFunction: () => { throw new Error( - "tailor.workflow.triggerJobFunction is not mocked. Use setupWorkflowMock() in tests.", + "tailor.workflow.startJobFunction is not mocked. Use setupWorkflowMock() in tests.", ); }, ...GlobalThis.tailor?.workflow, diff --git a/packages/sdk/src/vitest/globals.ts b/packages/sdk/src/vitest/globals.ts index 8af34c905..6ee283cd4 100644 --- a/packages/sdk/src/vitest/globals.ts +++ b/packages/sdk/src/vitest/globals.ts @@ -101,9 +101,10 @@ export function installPlatformGlobals(global: typeof globalThis): void { g[RUNTIME_FLAG_KEY] = true; // Containers. Namespace mocks (secretmanager, …) are added to these by the - // corresponding `xMock()` on acquisition. `workflow` carries a default runner - // so `.trigger()` runs the real job chain locally without `mockWorkflow()`; - // `mockWorkflow()` overlays and restores it. + // corresponding `xMock()` on acquisition. `workflow` carries a default + // runner: job/wait/resolve calls throw a helpful error, and workflow starts + // return a placeholder execution id, unless overlaid by `mockWorkflow()` or + // `runWorkflowLocally()`. g.tailor = { context: { getInvoker: defaultGetInvoker }, workflow: createDefaultWorkflowRuntime(), diff --git a/packages/sdk/src/vitest/index.ts b/packages/sdk/src/vitest/index.ts index 5be3f0d99..f97187380 100644 --- a/packages/sdk/src/vitest/index.ts +++ b/packages/sdk/src/vitest/index.ts @@ -80,4 +80,5 @@ export { type QueryMatcher, } from "./mock"; +export { runWorkflowLocally, type RunWorkflowLocallyOptions } from "./workflow-local"; export { createKyselyMock, type KyselyMock, type ExecutedQuery } from "./mock-kysely"; diff --git a/packages/sdk/src/vitest/integration/should-pass.test.ts b/packages/sdk/src/vitest/integration/should-pass.test.ts index 8ac1c4477..3a9ec0ded 100644 --- a/packages/sdk/src/vitest/integration/should-pass.test.ts +++ b/packages/sdk/src/vitest/integration/should-pass.test.ts @@ -31,7 +31,7 @@ test("base platform globals are injected; namespace mocks install on acquire", ( expect(g.TailorDBFileError).toBeTypeOf("function"); // Namespace mocks are installed once the corresponding mock is acquired. expect(g.tailordb.Client).toBeTypeOf("function"); - expect(g.tailor.workflow.triggerJobFunction).toBeTypeOf("function"); + expect(g.tailor.workflow.startJobFunction).toBeTypeOf("function"); }); test("setup.ts removes performance global during test execution", () => { diff --git a/packages/sdk/src/vitest/mock-ergonomics.test.ts b/packages/sdk/src/vitest/mock-ergonomics.test.ts index bdd96c28e..dc853c0f3 100644 --- a/packages/sdk/src/vitest/mock-ergonomics.test.ts +++ b/packages/sdk/src/vitest/mock-ergonomics.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { afterEach, beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; import { createWorkflowJob } from "../configure/services/workflow/job"; -import { defineWaitPoint } from "../configure/services/workflow/wait-point"; +import { createWaitPoint } from "../configure/services/workflow/wait-point"; import { createWorkflow } from "../configure/services/workflow/workflow"; import { cleanupMocks, @@ -29,7 +29,7 @@ const customerWorkflow = createWorkflow({ mainJob: lookupCustomer, }); -const approval = defineWaitPoint<{ message: string }, { approved: boolean }>( +const approval = createWaitPoint<{ message: string }, { approved: boolean }>( "mock-ergonomics-approval", ); @@ -152,11 +152,11 @@ describe("ergonomic runtime mocks", () => { job.mockResolvedValue({ customerId: "c-1", source: "mock" }); workflow.mockResolvedValue("execution-1"); - await expect(lookupCustomer.trigger({ customerId: "c-1" })).resolves.toEqual({ + await expect(lookupCustomer.start({ customerId: "c-1" })).resolves.toEqual({ customerId: "c-1", source: "mock", }); - await expect(customerWorkflow.trigger({ customerId: "c-1" })).resolves.toBe("execution-1"); + await expect(customerWorkflow.start({ customerId: "c-1" })).resolves.toBe("execution-1"); expect(job).toHaveBeenCalledWith({ customerId: "c-1" }); expect(workflow).toHaveBeenCalledWith({ customerId: "c-1" }); }); @@ -194,27 +194,26 @@ describe("ergonomic runtime mocks", () => { expect(innerJob).not.toBe(outerJob); expect(innerWorkflow).not.toBe(outerWorkflow); expect(innerWaitPoint.wait).not.toBe(outerWaitPoint.wait); - await expect(lookupCustomer.trigger({ customerId: "c-1" })).resolves.toMatchObject({ - source: "real", - }); + // A fresh inner scope does not inherit the outer scope's configured + // resolved value; the unconfigured start falls through to the real + // dispatch, which throws without a low-level job handler. + expect(() => lookupCustomer.start({ customerId: "c-1" })).toThrow(/No workflow job mock for/); innerJob.mockResolvedValue({ customerId: "c-1", source: "inner" }); innerWorkflow.mockResolvedValue("inner-execution"); innerWaitPoint.wait.mockResolvedValue({ approved: false }); - await expect(lookupCustomer.trigger({ customerId: "c-1" })).resolves.toMatchObject({ + await expect(lookupCustomer.start({ customerId: "c-1" })).resolves.toMatchObject({ source: "inner", }); - await expect(customerWorkflow.trigger({ customerId: "c-1" })).resolves.toBe( - "inner-execution", - ); + await expect(customerWorkflow.start({ customerId: "c-1" })).resolves.toBe("inner-execution"); await expect(approval.wait({ message: "inner" })).resolves.toEqual({ approved: false }); } - await expect(lookupCustomer.trigger({ customerId: "c-1" })).resolves.toMatchObject({ + await expect(lookupCustomer.start({ customerId: "c-1" })).resolves.toMatchObject({ source: "outer", }); - await expect(customerWorkflow.trigger({ customerId: "c-1" })).resolves.toBe("outer-execution"); + await expect(customerWorkflow.start({ customerId: "c-1" })).resolves.toBe("outer-execution"); await expect(approval.wait({ message: "outer" })).resolves.toEqual({ approved: true }); }); @@ -439,17 +438,14 @@ describe("ergonomic runtime mocks", () => { await expect(file.download(...args)).resolves.toMatchObject({ data: new Uint8Array() }); }); - test("resets typed workflow mocks to their real behavior", async () => { + test("resets typed workflow mocks to their unconfigured behavior", async () => { using wf = mockWorkflow(); const job = wf.job(lookupCustomer); job.mockResolvedValue({ customerId: "c-1", source: "mock" }); wf.reset(); - await expect(lookupCustomer.trigger({ customerId: "c-1" })).resolves.toEqual({ - customerId: "c-1", - source: "real", - }); + expect(() => lookupCustomer.start({ customerId: "c-1" })).toThrow(/No workflow job mock for/); }); test("resets IdP and File mocks to their fallback behavior", async () => { diff --git a/packages/sdk/src/vitest/mock-types.test.ts b/packages/sdk/src/vitest/mock-types.test.ts index 6800e723e..a834648bf 100644 --- a/packages/sdk/src/vitest/mock-types.test.ts +++ b/packages/sdk/src/vitest/mock-types.test.ts @@ -26,24 +26,12 @@ describe("mock types match @tailor-platform/sdk/runtime/globals", () => { }); describe("tailor.workflow", () => { - test("triggerWorkflow returns Promise", () => { - expectTypeOf(() => tailor.workflow.triggerWorkflow("wf", {})).returns.toEqualTypeOf< - Promise - >(); - }); - test("startWorkflow returns Promise", () => { expectTypeOf(() => tailor.workflow.startWorkflow("wf", {})).returns.toEqualTypeOf< Promise >(); }); - test("resumeWorkflow returns Promise", () => { - expectTypeOf(() => tailor.workflow.resumeWorkflow("exec-1")).returns.toEqualTypeOf< - Promise - >(); - }); - test("resumeWorkflowExecution returns Promise", () => { expectTypeOf(() => tailor.workflow.resumeWorkflowExecution("exec-1")).returns.toEqualTypeOf< Promise diff --git a/packages/sdk/src/vitest/mock.test.ts b/packages/sdk/src/vitest/mock.test.ts index d219504b5..a30e61d7a 100644 --- a/packages/sdk/src/vitest/mock.test.ts +++ b/packages/sdk/src/vitest/mock.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { createWorkflowJob, WORKFLOW_TEST_ENV_KEY } from "../configure/services/workflow/job"; +import { createWorkflowJob } from "../configure/services/workflow/job"; import { createWorkflow } from "../configure/services/workflow/workflow"; import { mockTailordb, @@ -14,6 +14,7 @@ import { cleanupMocks, RUNTIME_FLAG_KEY, } from "./mock"; +import { runWorkflowLocally } from "./workflow-local"; describe("mock", () => { beforeEach(() => { @@ -144,12 +145,12 @@ describe("mock", () => { vi.unstubAllEnvs(); }); - test("records triggered jobs", () => { + test("records started jobs even when no handler is configured", () => { using wf = mockWorkflow(); - const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - trigger("my-job", { key: "value" }); + const start = (globalThis as any).tailor.workflow.startJobFunction; + expect(() => start("my-job", { key: "value" })).toThrow(/No workflow job mock/); - expect(wf.triggeredJobs).toEqual([{ jobName: "my-job", args: { key: "value" } }]); + expect(wf.startedJobs).toEqual([{ jobName: "my-job", args: { key: "value" } }]); }); test("setJobHandler provides content-based responses", () => { @@ -159,8 +160,8 @@ describe("mock", () => { return null; }); - const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - const result = trigger("validate", {}); + const start = (globalThis as any).tailor.workflow.startJobFunction; + const result = start("validate", {}); expect(result).toEqual({ valid: true }); }); @@ -169,9 +170,9 @@ describe("mock", () => { using wf = mockWorkflow(); wf.enqueueResults({ step: 1 }, { step: 2 }); - const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - expect(trigger("job1", {})).toEqual({ step: 1 }); - expect(trigger("job2", {})).toEqual({ step: 2 }); + const start = (globalThis as any).tailor.workflow.startJobFunction; + expect(start("job1", {})).toEqual({ step: 1 }); + expect(start("job2", {})).toEqual({ step: 2 }); }); test("enqueueResult takes priority over jobHandler", () => { @@ -179,49 +180,67 @@ describe("mock", () => { wf.setJobHandler(() => ({ fallback: true })); wf.enqueueResult({ queued: true }); - const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - expect(trigger("job1", {})).toEqual({ queued: true }); - expect(trigger("job2", {})).toEqual({ fallback: true }); + const start = (globalThis as any).tailor.workflow.startJobFunction; + expect(start("job1", {})).toEqual({ queued: true }); + expect(start("job2", {})).toEqual({ fallback: true }); }); test("reset clears all state", () => { using wf = mockWorkflow(); - const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - trigger("job", {}); + const start = (globalThis as any).tailor.workflow.startJobFunction; + expect(() => start("job", {})).toThrow(/No workflow job mock/); wf.reset(); - expect(wf.triggeredJobs).toHaveLength(0); + expect(wf.startedJobs).toHaveLength(0); }); - test("setEnv exposes env to job bodies via .trigger()", async () => { + test("setEnv exposes env to job bodies via runWorkflowLocally()", async () => { using wf = mockWorkflow(); const captureEnv = createWorkflowJob({ name: "capture-env", body: (_input: undefined, ctx) => ctx.env, }); + const workflow = createWorkflow({ name: "capture-env-workflow", mainJob: captureEnv }); wf.setEnv({ STAGE: "test", REGION: "asia" }); - const env = await captureEnv.trigger(); + const env = await runWorkflowLocally(workflow); expect(env).toEqual({ STAGE: "test", REGION: "asia" }); }); + test("runWorkflowLocally env option overrides and restores the mock env", async () => { + using wf = mockWorkflow(); + const captureEnv = createWorkflowJob({ + name: "capture-env-option", + body: (_input: undefined, ctx) => ctx.env, + }); + const workflow = createWorkflow({ name: "capture-env-option-workflow", mainJob: captureEnv }); + + wf.setEnv({ STAGE: "from-mock" }); + + expect( + await runWorkflowLocally(workflow, undefined, { env: { STAGE: "from-option" } }), + ).toEqual({ STAGE: "from-option" }); + expect(await runWorkflowLocally(workflow)).toEqual({ STAGE: "from-mock" }); + }); + test("nested mockWorkflow scopes restore the outer env", async () => { using outer = mockWorkflow(); const captureEnv = createWorkflowJob({ name: "capture-nested-env", body: (_input: undefined, ctx) => ctx.env, }); + const workflow = createWorkflow({ name: "capture-nested-env-workflow", mainJob: captureEnv }); outer.setEnv({ STAGE: "outer" }); { using inner = mockWorkflow(); inner.setEnv({ STAGE: "inner" }); - expect(await captureEnv.trigger()).toEqual({ STAGE: "inner" }); + expect(await runWorkflowLocally(workflow)).toEqual({ STAGE: "inner" }); } - expect(await captureEnv.trigger()).toEqual({ STAGE: "outer" }); + expect(await runWorkflowLocally(workflow)).toEqual({ STAGE: "outer" }); }); test("reset clears env back to {}", async () => { @@ -230,87 +249,196 @@ describe("mock", () => { name: "capture-env-reset", body: (_input: undefined, ctx) => ctx.env, }); + const workflow = createWorkflow({ name: "capture-env-reset-workflow", mainJob: captureEnv }); wf.setEnv({ STAGE: "test" }); wf.reset(); - expect(await captureEnv.trigger()).toEqual({}); + expect(await runWorkflowLocally(workflow)).toEqual({}); }); - describe("backward-compat: deprecated WORKFLOW_TEST_ENV_KEY env-var", () => { - test("setEnv takes priority over the env-var", async () => { - using wf = mockWorkflow(); - const captureEnv = createWorkflowJob({ - name: "capture-env-compat-priority", - body: (_input: undefined, ctx) => ctx.env, - }); - - vi.stubEnv(WORKFLOW_TEST_ENV_KEY, JSON.stringify({ STAGE: "fallback" })); - wf.setEnv({ STAGE: "from-setenv" }); - - expect(await captureEnv.trigger()).toEqual({ STAGE: "from-setenv" }); + test("ignores the legacy TAILOR_TEST_WORKFLOW_ENV env-var", async () => { + const captureEnv = createWorkflowJob({ + name: "capture-env-ignore-legacy-env", + body: (_input: undefined, ctx) => ctx.env, }); - - test("env-var is used when setEnv has not been called", async () => { - const captureEnv = createWorkflowJob({ - name: "capture-env-compat-fallback", - body: (_input: undefined, ctx) => ctx.env, - }); - - vi.stubEnv(WORKFLOW_TEST_ENV_KEY, JSON.stringify({ STAGE: "from-env-var" })); - - expect(await captureEnv.trigger()).toEqual({ STAGE: "from-env-var" }); + const workflow = createWorkflow({ + name: "capture-env-ignore-legacy-env-workflow", + mainJob: captureEnv, }); - test("throws when the env-var is valid JSON but not an object", async () => { - using _wf = mockWorkflow(); - const captureEnv = createWorkflowJob({ - name: "capture-env-compat-nonobject", - body: (_input: undefined, ctx) => ctx.env, - }); - - vi.stubEnv(WORKFLOW_TEST_ENV_KEY, "42"); + vi.stubEnv("TAILOR_TEST_WORKFLOW_ENV", JSON.stringify({ STAGE: "from-env-var" })); - await expect(captureEnv.trigger()).rejects.toThrow(/must be a JSON object/); - }); + expect(await runWorkflowLocally(workflow)).toEqual({}); }); }); describe("default workflow runtime (no mockWorkflow needed)", () => { - test("a job's .trigger() runs its real body", async () => { + test("a job's .start() requires a configured workflow mock", () => { const double = createWorkflowJob({ name: "default-runtime-double", body: (input: { n: number }) => ({ doubled: input.n * 2 }), }); - expect(await double.trigger({ n: 21 })).toEqual({ doubled: 42 }); + expect(() => double.start({ n: 21 })).toThrow(/No workflow job mock/); + }); + + test("workflow.start() returns an execution id without running the main job", async () => { + let bodyRan = false; + const main = createWorkflowJob({ + name: "default-runtime-main-start", + body: () => { + bodyRan = true; + return { ok: true }; + }, + }); + const workflow = createWorkflow({ name: "default-runtime-start-wf", mainJob: main }); + + await expect(workflow.start(undefined)).resolves.toBe("00000000-0000-4000-8000-000000000000"); + expect(bodyRan).toBe(false); + }); + + test("workflow.start() validates args in the default runtime", async () => { + const main = createWorkflowJob({ + name: "default-runtime-start-args", + body: (input: { when: string }) => ({ when: input.when }), + }); + const workflow = createWorkflow({ name: "default-runtime-start-args-wf", mainJob: main }); + + await expect(workflow.start({ when: new Date() } as never)).rejects.toThrow(/Date instance/); }); - test("workflow.mainJob.trigger() runs the whole chain", async () => { + test("runWorkflowLocally() runs the whole chain", async () => { const inner = createWorkflowJob({ name: "default-runtime-inner", - body: (input: { n: number }) => ({ n: input.n + 1 }), + body: async (input: { n: number }) => ({ n: input.n + 1 }), }); const main = createWorkflowJob({ name: "default-runtime-main", body: async (input: { n: number }) => { - const a = await inner.trigger({ n: input.n }); - const b = await inner.trigger({ n: a.n }); + const a = inner.start({ n: input.n }); + const b = inner.start({ n: a.n }); return { total: b.n }; }, }); const workflow = createWorkflow({ name: "default-runtime-wf", mainJob: main }); - expect(await workflow.mainJob.trigger({ n: 0 })).toEqual({ total: 2 }); + expect(await runWorkflowLocally(workflow, { n: 0 })).toEqual({ total: 2 }); + }); + + test("runWorkflowLocally() clones cached start results on replay", async () => { + const mutable = createWorkflowJob({ + name: "default-runtime-mutable", + body: () => ({ items: [] as string[] }), + }); + const step = createWorkflowJob({ + name: "default-runtime-step", + body: () => ({ ok: true }), + }); + const main = createWorkflowJob({ + name: "default-runtime-mutation-main", + body: () => { + const result = mutable.start(); + result.items.push("x"); + step.start(); + return result; + }, + }); + const workflow = createWorkflow({ + name: "default-runtime-mutation-wf", + mainJob: main, + }); + + expect(await runWorkflowLocally(workflow)).toEqual({ items: ["x"] }); + }); + + test("runWorkflowLocally() hides replay signals from user catch blocks", async () => { + const inner = createWorkflowJob({ + name: "default-runtime-caught-inner", + body: async () => ({ ok: true }), + }); + const main = createWorkflowJob({ + name: "default-runtime-caught-main", + body: () => { + try { + return inner.start(); + } catch { + return { ok: false }; + } + }, + }); + const workflow = createWorkflow({ + name: "default-runtime-caught-wf", + mainJob: main, + }); + + expect(await runWorkflowLocally(workflow)).toEqual({ ok: true }); + }); + + test("runWorkflowLocally() replays failed start errors into user catch blocks once", async () => { + let attempts = 0; + const inner = createWorkflowJob({ + name: "default-runtime-failing-inner", + body: async () => { + attempts += 1; + throw new Error("inner failed"); + }, + }); + const main = createWorkflowJob({ + name: "default-runtime-failing-main", + body: () => { + try { + inner.start(); + return { handled: false, message: "" }; + } catch (cause) { + return { handled: true, message: (cause as Error).message }; + } + }, + }); + const workflow = createWorkflow({ + name: "default-runtime-failing-wf", + mainJob: main, + }); + + expect(await runWorkflowLocally(workflow)).toEqual({ + handled: true, + message: "inner failed", + }); + expect(attempts).toBe(1); + }); + + test("runWorkflowLocally() validates nested workflow start args", async () => { + const innerMain = createWorkflowJob({ + name: "default-runtime-nested-workflow-inner", + body: (_input: { when: string }) => ({ ok: true }), + }); + const innerWorkflow = createWorkflow({ + name: "default-runtime-nested-workflow", + mainJob: innerMain, + }); + const main = createWorkflowJob({ + name: "default-runtime-nested-workflow-main", + body: async () => { + await innerWorkflow.start({ when: new Date() } as never); + return { ok: true }; + }, + }); + const workflow = createWorkflow({ + name: "default-runtime-nested-workflow-wf", + mainJob: main, + }); + + await expect(runWorkflowLocally(workflow)).rejects.toThrow(/Date instance/); }); - test("the JSON boundary rejects a non-serializable trigger result", async () => { + test("runWorkflowLocally() rejects a non-serializable start result", async () => { const bad = createWorkflowJob({ name: "default-runtime-bad", body: () => ({ when: new Date() }) as never, }); + const workflow = createWorkflow({ name: "default-runtime-bad-wf", mainJob: bad }); - await expect(bad.trigger()).rejects.toThrow(/Date instance/); + await expect(runWorkflowLocally(workflow)).rejects.toThrow(/Date instance/); }); }); @@ -618,160 +746,9 @@ describe("mock", () => { expect(calls).toHaveLength(0); }); - test("openDownloadStream rejects raw bytes to guide callers to structured chunks", async () => { - using file = mockFile(); - file.enqueueResult(new Uint8Array([1, 2, 3])); - await expect( - (globalThis as any).tailordb.file.openDownloadStream("ns", "T", "f", "r"), - ).rejects.toThrow(/iterable of StreamValue items/); - }); - - test("openDownloadStream rejects non-StreamValue elements yielded by the iterable", async () => { - using file = mockFile(); - // Uint8Array[] is iterable but its elements aren't StreamValue items. - file.enqueueResult([new Uint8Array([1]), new Uint8Array([2])]); - const stream = await (globalThis as any).tailordb.file.openDownloadStream( - "ns", - "T", - "f", - "r", - ); - await expect(stream.next()).rejects.toThrow(/StreamValue/); - }); - - test("openDownloadStream validates elements from an existing stream iterator", async () => { - using file = mockFile(); - const invalidStream = { - async next() { - return { done: false as const, value: new Uint8Array([1]) }; - }, - async close() {}, - [Symbol.asyncIterator]() { - return this; - }, - }; - file.enqueueResult(invalidStream); - - const stream = await (globalThis as any).tailordb.file.openDownloadStream( - "ns", - "T", - "f", - "r", - ); - await expect(stream.next()).rejects.toThrow(/StreamValue/); - }); - - test("openDownloadStream closes the wrapped iterator", async () => { - using file = mockFile(); - let closed = false; - async function* sequence() { - try { - yield { type: "complete" as const }; - } finally { - closed = true; - } - } - file.enqueueResult(sequence()); - - const stream = await (globalThis as any).tailordb.file.openDownloadStream( - "ns", - "T", - "f", - "r", - ); - await stream.next(); - await stream.close(); - expect(closed).toBe(true); - }); - - test("openDownloadStream acquires the iterator from an existing stream", async () => { - using file = mockFile(); - let acquired = false; - const source = { - async close() {}, - async *[Symbol.asyncIterator]() { - acquired = true; - yield { type: "complete" as const }; - }, - }; - file.enqueueResult(source); - - const stream = await (globalThis as any).tailordb.file.openDownloadStream( - "ns", - "T", - "f", - "r", - ); - await expect(stream.next()).resolves.toMatchObject({ value: { type: "complete" } }); - expect(acquired).toBe(true); - }); - - test("openDownloadStream closes the iterator when iteration stops early", async () => { - using file = mockFile(); - let closed = false; - async function* sequence() { - try { - yield { type: "complete" as const }; - } finally { - closed = true; - } - } - file.enqueueResult(sequence()); - - const stream = await (globalThis as any).tailordb.file.openDownloadStream( - "ns", - "T", - "f", - "r", - ); - for await (const _value of stream) break; - expect(closed).toBe(true); - }); - - test("openDownloadStream closes the source when iteration stops early", async () => { - using file = mockFile(); - let closed = false; - const source = { - async close() { - closed = true; - }, - async *[Symbol.asyncIterator]() { - yield { type: "complete" as const }; - }, - }; - file.enqueueResult(source); - - const stream = await (globalThis as any).tailordb.file.openDownloadStream( - "ns", - "T", - "f", - "r", - ); - for await (const _value of stream) break; - expect(closed).toBe(true); - }); - - test("openDownloadStream yields the enqueued StreamValue sequence", async () => { - using file = mockFile(); - const bytes = new Uint8Array([1, 2, 3]); - const sequence = [ - { - type: "metadata" as const, - metadata: { contentType: "application/octet-stream", fileSize: 3, sha256sum: "h" }, - }, - { type: "chunk" as const, data: bytes, position: 0 }, - { type: "complete" as const }, - ]; - file.enqueueResult(sequence); - const stream = await (globalThis as any).tailordb.file.openDownloadStream( - "ns", - "T", - "f", - "r", - ); - const chunks: unknown[] = []; - for await (const chunk of stream) chunks.push(chunk); - expect(chunks).toEqual(sequence); + test("does not install the removed openDownloadStream file mock", () => { + using _file = mockFile(); + expect("openDownloadStream" in (globalThis as any).tailordb.file).toBe(false); }); test("default fallback is cloned so test mutations cannot leak across tests", async () => { @@ -852,19 +829,19 @@ describe("mock", () => { }); describe("mockWorkflow extended", () => { - test("records triggerWorkflow calls", async () => { + test("records startWorkflow calls", async () => { using wf = mockWorkflow(); - await (globalThis as any).tailor.workflow.triggerWorkflow("wf-1", { key: "val" }); - expect(wf.triggerWorkflow.mock.calls).toEqual([["wf-1", { key: "val" }]]); + await (globalThis as any).tailor.workflow.startWorkflow("wf-1", { key: "val" }); + expect(wf.startWorkflow.mock.calls).toEqual([["wf-1", { key: "val" }]]); }); test("preserves a forwarded third options arg even when undefined", async () => { using wf = mockWorkflow(); - await (globalThis as any).tailor.workflow.triggerWorkflow("wf-1", { key: "val" }, undefined); - expect(wf.triggerWorkflow.mock.calls).toEqual([["wf-1", { key: "val" }, undefined]]); + await (globalThis as any).tailor.workflow.startWorkflow("wf-1", { key: "val" }, undefined); + expect(wf.startWorkflow.mock.calls).toEqual([["wf-1", { key: "val" }, undefined]]); }); - test("createWorkflow().trigger(args) without options records a 2-arg call", async () => { + test("createWorkflow().start(args) without options records a 2-arg call", async () => { using wf = mockWorkflow(); const arityJob = createWorkflowJob({ name: "arity-workflow-main", @@ -872,12 +849,12 @@ describe("mock", () => { }); const arityWorkflow = createWorkflow({ name: "arity-workflow", mainJob: arityJob }); - await arityWorkflow.trigger(undefined); + await arityWorkflow.start(undefined); - expect(wf.triggerWorkflow.mock.calls).toEqual([["arity-workflow", undefined]]); + expect(wf.startWorkflow.mock.calls).toEqual([["arity-workflow", undefined]]); }); - test("createWorkflow().trigger(args, undefined) records a 3-arg call", async () => { + test("createWorkflow().start(args, undefined) records a 3-arg call", async () => { using wf = mockWorkflow(); const arityJob = createWorkflowJob({ name: "arity-workflow-main-3arg", @@ -885,28 +862,26 @@ describe("mock", () => { }); const arityWorkflow = createWorkflow({ name: "arity-workflow-3arg", mainJob: arityJob }); - await arityWorkflow.trigger(undefined, undefined); + await arityWorkflow.start(undefined, undefined); - expect(wf.triggerWorkflow.mock.calls).toEqual([ - ["arity-workflow-3arg", undefined, undefined], - ]); + expect(wf.startWorkflow.mock.calls).toEqual([["arity-workflow-3arg", undefined, undefined]]); }); - test("setTriggerHandler with string controls triggerWorkflow response", async () => { + test("setStartHandler with string controls startWorkflow response", async () => { using wf = mockWorkflow(); - wf.setTriggerHandler("exec-123"); - const result = await (globalThis as any).tailor.workflow.triggerWorkflow("wf"); + wf.setStartHandler("exec-123"); + const result = await (globalThis as any).tailor.workflow.startWorkflow("wf"); expect(result).toBe("exec-123"); }); - test("setTriggerHandler with function receives name/args/options", async () => { + test("setStartHandler with function receives name/args/options", async () => { using wf = mockWorkflow(); const seen: unknown[] = []; - wf.setTriggerHandler((name, args, options) => { + wf.setStartHandler((name, args, options) => { seen.push({ name, args, options }); return `exec-${name}`; }); - const result = await (globalThis as any).tailor.workflow.triggerWorkflow( + const result = await (globalThis as any).tailor.workflow.startWorkflow( "wf", { key: "val" }, { authInvoker: { namespace: "ns", machineUserName: "mu" } }, @@ -921,17 +896,19 @@ describe("mock", () => { ]); }); - test("resumeWorkflow echoes the executionId by default", async () => { + test("resumeWorkflowExecution echoes the executionId by default", async () => { using wf = mockWorkflow(); - const result = await (globalThis as any).tailor.workflow.resumeWorkflow("exec-42"); + const result = await (globalThis as any).tailor.workflow.resumeWorkflowExecution("exec-42"); expect(result).toBe("exec-42"); - expect(wf.resumeWorkflow.mock.calls).toEqual([["exec-42"]]); + expect(wf.resumeWorkflowExecution.mock.calls).toEqual([["exec-42"]]); }); - test("setResumeHandler with string controls resumeWorkflow response", async () => { + test("setResumeHandler with string controls resumeWorkflowExecution response", async () => { using wf = mockWorkflow(); wf.setResumeHandler("exec-resumed"); - const result = await (globalThis as any).tailor.workflow.resumeWorkflow("exec-original"); + const result = await (globalThis as any).tailor.workflow.resumeWorkflowExecution( + "exec-original", + ); expect(result).toBe("exec-resumed"); }); @@ -942,7 +919,7 @@ describe("mock", () => { seen.push(executionId); return `resumed:${executionId}`; }); - const result = await (globalThis as any).tailor.workflow.resumeWorkflow("exec-1"); + const result = await (globalThis as any).tailor.workflow.resumeWorkflowExecution("exec-1"); expect(result).toBe("resumed:exec-1"); expect(seen).toEqual(["exec-1"]); }); @@ -998,41 +975,6 @@ describe("mock", () => { expect(callbackRan).toBe(false); expect(wf.resolveCalls).toEqual([{ executionId: "exec-1", key: "approval" }]); }); - - describe("canonical aliases", () => { - test("startJobFunction and triggerJobFunction share the same call log", () => { - using wf = mockWorkflow(); - const start = (globalThis as any).tailor.workflow.startJobFunction; - const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - - start("job-a", { via: "canonical" }); - trigger("job-b", { via: "legacy" }); - - expect(wf.triggeredJobs).toEqual([ - { jobName: "job-a", args: { via: "canonical" } }, - { jobName: "job-b", args: { via: "legacy" } }, - ]); - expect(wf.startJobFunction).toBe(wf.triggerJobFunction); - }); - - test("startWorkflow honors setTriggerHandler", async () => { - using wf = mockWorkflow(); - wf.setTriggerHandler("exec-canonical"); - const result = await (globalThis as any).tailor.workflow.startWorkflow("wf", {}); - expect(result).toBe("exec-canonical"); - expect(wf.startWorkflow.mock.calls).toEqual([["wf", {}]]); - expect(wf.startWorkflow).toBe(wf.triggerWorkflow); - }); - - test("resumeWorkflowExecution honors setResumeHandler", async () => { - using wf = mockWorkflow(); - wf.setResumeHandler("exec-canonical-resumed"); - const result = await (globalThis as any).tailor.workflow.resumeWorkflowExecution("exec-1"); - expect(result).toBe("exec-canonical-resumed"); - expect(wf.resumeWorkflowExecution.mock.calls).toEqual([["exec-1"]]); - expect(wf.resumeWorkflowExecution).toBe(wf.resumeWorkflow); - }); - }); }); describe("injectMocks / cleanupMocks (base platform globals)", () => { @@ -1056,11 +998,11 @@ describe("mock", () => { test("a mock overlays the default workflow runner and dispose restores it", () => { const base = (globalThis as any).tailor.workflow; - expect(base.triggerJobFunction).toBeTypeOf("function"); + expect(base.startJobFunction).toBeTypeOf("function"); { using _wf = mockWorkflow(); expect((globalThis as any).tailor.workflow).not.toBe(base); - expect((globalThis as any).tailor.workflow.triggerJobFunction).toBeTypeOf("function"); + expect((globalThis as any).tailor.workflow.startJobFunction).toBeTypeOf("function"); } expect((globalThis as any).tailor.workflow).toBe(base); }); diff --git a/packages/sdk/src/vitest/mocks/file.ts b/packages/sdk/src/vitest/mocks/file.ts index e8786d403..86a973469 100644 --- a/packages/sdk/src/vitest/mocks/file.ts +++ b/packages/sdk/src/vitest/mocks/file.ts @@ -5,7 +5,6 @@ import type { FileDownloadResponse, FileDownloadStreamResponse, FileMetadata, - FileStreamIterator, FileUploadResponse, TailorDBFileAPI, } from "../../runtime/file"; @@ -37,7 +36,6 @@ const FILE_METHODS = [ "downloadAsBase64", "delete", "getMetadata", - "openDownloadStream", "downloadStream", "uploadStream", ] as const satisfies readonly FileMethod[]; @@ -57,111 +55,6 @@ const FILE_DEFAULTS: Partial> = { uploadStream: { metadata: { fileSize: 0, sha256sum: "" } }, }; -function wrapFileIterator( - inner: Iterator | AsyncIterator, - closeSource: () => void | Promise, -): FileStreamIterator { - let closePromise: Promise | undefined; - const closeSourceOnce = () => (closePromise ??= Promise.resolve(closeSource())); - const close = async () => { - try { - await inner.return?.(); - } finally { - await closeSourceOnce(); - } - }; - const stream = { - async next() { - const result = await inner.next(); - if (!result.done) assertStreamValue(result.value); - return result.done ? { done: true as const, value: undefined } : result; - }, - close, - async return(value?: unknown) { - try { - return inner.return ? await inner.return(value) : { done: true as const, value }; - } finally { - await closeSourceOnce(); - } - }, - async throw(error?: unknown) { - try { - if (inner.throw) return await inner.throw(error); - throw error; - } finally { - await closeSourceOnce(); - } - }, - [Symbol.asyncIterator]() { - return stream; - }, - } as FileStreamIterator; - return stream; -} - -function toFileStream(value: unknown): FileStreamIterator { - if ( - value !== null && - typeof value === "object" && - Symbol.asyncIterator in value && - typeof (value as { close?: unknown }).close === "function" - ) { - const source = value as AsyncIterable & { close(): Promise }; - return wrapFileIterator(source[Symbol.asyncIterator](), () => source.close()); - } - if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { - throw new TypeError( - "openDownloadStream expects an iterable of StreamValue items " + - '(e.g. [{ type: "chunk", data, position }, { type: "complete" }]); ' + - "got raw bytes. Wrap the bytes in a structured chunk first.", - ); - } - if ( - value !== null && - typeof value === "object" && - (Symbol.iterator in value || Symbol.asyncIterator in value) - ) { - const source = value as Iterable | AsyncIterable; - const inner = - Symbol.asyncIterator in source - ? (source as AsyncIterable)[Symbol.asyncIterator]() - : (source as Iterable)[Symbol.iterator](); - return wrapFileIterator(inner, () => {}); - } - const empty = { - async next() { - return { done: true as const, value: undefined }; - }, - async close() {}, - [Symbol.asyncIterator]() { - return empty; - }, - } as FileStreamIterator; - return empty; -} - -function assertStreamValue(value: unknown): void { - if (value === null || typeof value !== "object") { - throw new TypeError( - 'openDownloadStream expected a StreamValue item ({ type: "metadata" | "chunk" | "complete", ... }); ' + - `got ${typeof value === "object" ? "null" : typeof value}.`, - ); - } - if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { - throw new TypeError( - "openDownloadStream expected a StreamValue item, got raw bytes. " + - 'Wrap the bytes in a structured chunk first (e.g. { type: "chunk", data, position }).', - ); - } - const type = (value as { type?: unknown }).type; - if (type !== "metadata" && type !== "chunk" && type !== "complete") { - throw new TypeError( - 'openDownloadStream expected a StreamValue item with type "metadata" | "chunk" | "complete"; ' + - `got ${JSON.stringify(type)}.`, - ); - } -} - /** * Acquire a disposable mock for `tailordb.file`. Restored on dispose. * @param options - Controls behavior for calls without a configured result @@ -220,9 +113,6 @@ export function mockFile(options: MockFileOptions = {}) { const getMetadata = vi.fn( async (...args) => handle("getMetadata", ...args) as FileMetadata, ); - const openDownloadStream = vi.fn(async (...args) => - toFileStream(handle("openDownloadStream", ...args)), - ); const downloadStream = vi.fn(async (...args) => { const resolved = handle("downloadStream", ...args); if (resolved != null) return resolved as FileDownloadStreamResponse; @@ -246,7 +136,6 @@ export function mockFile(options: MockFileOptions = {}) { downloadAsBase64, delete: deleteFile, getMetadata, - openDownloadStream, downloadStream, uploadStream, }; @@ -277,7 +166,6 @@ export function mockFile(options: MockFileOptions = {}) { downloadAsBase64: track("downloadAsBase64", downloadAsBase64), delete: track("delete", deleteFile), getMetadata: track("getMetadata", getMetadata), - openDownloadStream: track("openDownloadStream", openDownloadStream), downloadStream: track("downloadStream", downloadStream), uploadStream: track("uploadStream", uploadStream), }; diff --git a/packages/sdk/src/vitest/mocks/workflow.ts b/packages/sdk/src/vitest/mocks/workflow.ts index 796188e3f..6b8f07f14 100644 --- a/packages/sdk/src/vitest/mocks/workflow.ts +++ b/packages/sdk/src/vitest/mocks/workflow.ts @@ -1,12 +1,7 @@ import { type Mock, vi } from "vitest"; -import { - getRegisteredJob, - getRegisteredWorkflow, - TRIGGER_DEFAULT, -} from "#/configure/services/workflow/registry"; +import { START_DEFAULT } from "#/configure/services/workflow/registry"; import { platformSerialize } from "#/utils/test/platform-serialize"; import { - buildJobContext, clearWorkflowTestEnv, readWorkflowTestEnv, writeWorkflowTestEnv, @@ -20,7 +15,7 @@ import type { TailorEnv } from "../../runtime/types"; type JobHandler = (jobName: string, args: unknown, options?: StartJobFunctionOptions) => unknown; -type TriggerHandlerFn = ( +type StartHandlerFn = ( workflowName: string, args: unknown, options?: StartWorkflowOptions, @@ -42,7 +37,7 @@ type SetWaitHandler = { (handler: unknown): void; }; -interface TriggeredJob { +interface StartedJob { jobName: string; args: unknown; options?: StartJobFunctionOptions; @@ -55,13 +50,13 @@ interface ScopedMock { } type WaitPayload = [Payload] extends [undefined] ? undefined : Payload; -type TriggerProcedure = (...args: never[]) => unknown; +type ProcedureFn = (...args: never[]) => unknown; // `vi.spyOn` reuses an existing spy for the same property. Keep the base // procedure separately so nested mockWorkflow scopes get independent mocks // while disposal can still restore the immediately preceding scope. -const originalProcedures = new WeakMap(); +const originalProcedures = new WeakMap(); -function replaceProcedure( +function replaceProcedure( target: object, key: string, current: Procedure, @@ -74,7 +69,7 @@ function replaceProcedure( return Reflect.apply(original, this, args) as ReturnType; }; const mock = vi.fn(defaultImplementation) as unknown as Mock; - originalProcedures.set(mock as TriggerProcedure, original); + originalProcedures.set(mock as ProcedureFn, original); const record = target as Record; record[key] = mock; @@ -91,10 +86,10 @@ function replaceProcedure( }; } -function replaceTrigger(definition: { - trigger: Trigger; -}): { mock: Mock; scoped: ScopedMock } { - return replaceProcedure(definition, "trigger", definition.trigger); +function replaceStart(definition: { + start: Start; +}): { mock: Mock; scoped: ScopedMock } { + return replaceProcedure(definition, "start", definition.start); } // --------------------------------------------------------------------------- @@ -104,17 +99,12 @@ function replaceTrigger(definition: { /** * Acquire a disposable mock for workflow operations (`tailor.workflow`). * Restored on dispose. - * - * Canonical names (`startWorkflow`, `startJobFunction`, `resumeWorkflowExecution`) - * and their frozen aliases (`triggerWorkflow`, `triggerJobFunction`, `resumeWorkflow`) - * share the same underlying `vi.fn`, so calls through either name are recorded - * once and handlers configured on either name apply to both. * @returns Disposable workflow mock control object * @example * ```typescript * import { mockWorkflow } from "@tailor-platform/sdk/vitest"; * - * test("job trigger", async () => { + * test("job start", async () => { * using wf = mockWorkflow(); * const job = wf.job(validateOrder); * job.mockResolvedValue({ valid: true }); @@ -132,36 +122,30 @@ export function mockWorkflow() { const waitPointMocks = new Map(); const scopedMocks = new Set(); - // Default impls (also restored by reset): run the registered body by name so a - // `.trigger()` with no handler/result executes the real job locally. - const defaultTriggerJob = ( + const defaultStartJob = ( jobName: string, - args?: unknown, + _args?: unknown, _options?: StartJobFunctionOptions, ): unknown => { - const body = getRegisteredJob(jobName); - return body ? body(args, buildJobContext()) : null; + throw new Error( + `No workflow job mock for "${jobName}". Call mockWorkflow().setJobHandler(...) or enqueueResult(...), or use runWorkflowLocally() for local workflow execution.`, + ); }; - const defaultTriggerWorkflow = async ( - workflowName: string, - args?: unknown, + const defaultStartWorkflow = async ( + _workflowName: string, + _args?: unknown, _options?: StartWorkflowOptions, ): Promise => { - const wf = getRegisteredWorkflow(workflowName); - if (wf) { - const out = triggerJobFunction(wf.mainJobName, platformSerialize(args)); - if (out instanceof Promise) await out; - } - return TRIGGER_DEFAULT; + return START_DEFAULT; }; - const defaultResumeWorkflow = async (executionId: string): Promise => executionId; + const defaultResumeWorkflowExecution = async (executionId: string): Promise => + executionId; // Inner vi.fns hold the overridable behavior + call recording; the installed - // shims below cross the platform JSON boundary (serialize args + results) once - // so every path (default body, setJobHandler, enqueueResult) is covered. - const triggerJobFunction = vi.fn(defaultTriggerJob); - const triggerWorkflow = vi.fn(defaultTriggerWorkflow); - const resumeWorkflow = vi.fn(defaultResumeWorkflow); + // shims below cross the platform JSON boundary (serialize args + results) once. + const startJobFunction = vi.fn(defaultStartJob); + const startWorkflow = vi.fn(defaultStartWorkflow); + const resumeWorkflowExecution = vi.fn(defaultResumeWorkflowExecution); const wait = vi.fn((_key: string, _payload?: unknown): unknown => null); const resolve = vi.fn( async ( @@ -172,30 +156,25 @@ export function mockWorkflow() { ); // Preserve arity: recording `undefined` as the third element only when the - // caller supplied it, mirroring `.triggerJobFunction(name, args, options)`. + // caller supplied it, mirroring `.startJobFunction(name, args, options)`. const jobFunctionShim = (...call: [string, unknown?, StartJobFunctionOptions?]) => { const out = call.length >= 3 - ? triggerJobFunction(call[0], platformSerialize(call[1]), call[2]) - : triggerJobFunction(call[0], platformSerialize(call[1])); + ? startJobFunction(call[0], platformSerialize(call[1]), call[2]) + : startJobFunction(call[0], platformSerialize(call[1])); return out instanceof Promise ? out.then((v) => platformSerialize(v)) : platformSerialize(out); }; // Preserve arity so a forwarded third `options` arg — even `undefined` — is - // recorded, matching the real `.trigger(args, options)` call shape. + // recorded, matching the real `.start(args, options)` call shape. const workflowShim = (...call: [string, unknown?, StartWorkflowOptions?]) => call.length >= 3 - ? triggerWorkflow(call[0], platformSerialize(call[1]), call[2]) - : triggerWorkflow(call[0], platformSerialize(call[1])); - const resumeShim = (executionId: string) => resumeWorkflow(executionId); + ? startWorkflow(call[0], platformSerialize(call[1]), call[2]) + : startWorkflow(call[0], platformSerialize(call[1])); + const resumeShim = (executionId: string) => resumeWorkflowExecution(executionId); root.workflow = { - // Canonical and frozen alias names share a single shim so calls through - // either name are recorded on the same underlying vi.fn. startJobFunction: jobFunctionShim, - triggerJobFunction: jobFunctionShim, startWorkflow: workflowShim, - triggerWorkflow: workflowShim, resumeWorkflowExecution: resumeShim, - resumeWorkflow: resumeShim, wait: (key: string, payload?: unknown) => wait(key, platformSerialize(payload)), resolve: (executionId: string, key: string, callback: (payload: unknown) => unknown) => resolve(executionId, key, (payload: unknown) => { @@ -208,63 +187,47 @@ export function mockWorkflow() { const facade = { /** The `startJobFunction` `vi.fn`. */ - startJobFunction: triggerJobFunction, - /** - * Frozen alias of `startJobFunction` (same `vi.fn` reference). - * @deprecated Use `startJobFunction` instead. - */ - triggerJobFunction, + startJobFunction, /** The `startWorkflow` `vi.fn`. */ - startWorkflow: triggerWorkflow, - /** - * Frozen alias of `startWorkflow` (same `vi.fn` reference). - * @deprecated Use `startWorkflow` instead. - */ - triggerWorkflow, + startWorkflow, /** The `resumeWorkflowExecution` `vi.fn`. */ - resumeWorkflowExecution: resumeWorkflow, - /** - * Frozen alias of `resumeWorkflowExecution` (same `vi.fn` reference). - * @deprecated Use `resumeWorkflowExecution` instead. - */ - resumeWorkflow, + resumeWorkflowExecution, /** The `wait` `vi.fn`. */ wait, /** The `resolve` `vi.fn`. */ resolve, /** - * Get a stable, typed mock for a workflow job's `trigger` method. - * The real trigger behavior is used until an implementation or result is configured. + * Get a stable, typed mock for a workflow job's `start` method. + * The real start behavior is used until an implementation or result is configured. * @param definition - Workflow job definition to mock - * @returns Typed `trigger` mock for the definition + * @returns Typed `start` mock for the definition */ job( definition: WorkflowJob, - ): Mock["trigger"]> { + ): Mock["start"]> { const existing = jobSpies.get(definition); if (existing) { - return existing as Mock["trigger"]>; + return existing as Mock["start"]>; } - const { mock, scoped } = - replaceTrigger["trigger"]>(definition); + const { mock, scoped } = replaceStart["start"]>(definition); scopedMocks.add(scoped); jobSpies.set(definition, mock); return mock; }, /** - * Get a stable, typed mock for a workflow definition's `trigger` method. - * The real trigger behavior is used until an implementation or result is configured. + * Get a stable, typed mock for a workflow definition's `start` method. + * The real start behavior is used until an implementation or result is configured. * @param definition - Workflow definition to mock - * @returns Typed `trigger` mock for the definition + * @returns Typed `start` mock for the definition */ - workflow(definition: Definition): Mock { + workflow(definition: Definition): Mock { const existing = workflowSpies.get(definition); - if (existing) return existing as Mock; + if (existing) return existing as Mock; - const { mock, scoped } = replaceTrigger(definition); + const { mock, scoped } = replaceStart(definition); workflowSpies.set(definition, mock); scopedMocks.add(scoped); return mock; @@ -317,34 +280,34 @@ export function mockWorkflow() { * @param handler - Function returning a result for a job name, args, and options */ setJobHandler(handler: JobHandler): void { - triggerJobFunction.mockImplementation((name, args, options) => handler(name, args, options)); + startJobFunction.mockImplementation((name, args, options) => handler(name, args, options)); }, /** - * Enqueue a single result for the next `triggerJobFunction` call (FIFO; + * Enqueue a single result for the next `startJobFunction` call (FIFO; * takes priority over `setJobHandler`). * @param result - Result to return from the next call */ enqueueResult(result: unknown): void { - triggerJobFunction.mockImplementationOnce(() => result); + startJobFunction.mockImplementationOnce(() => result); }, /** - * Enqueue results for multiple subsequent `triggerJobFunction` calls (FIFO). + * Enqueue results for multiple subsequent `startJobFunction` calls (FIFO). * @param results - Results to enqueue, one per upcoming call */ enqueueResults(...results: unknown[]): void { for (const result of results) { - triggerJobFunction.mockImplementationOnce(() => result); + startJobFunction.mockImplementationOnce(() => result); } }, /** - * All jobs triggered via `triggerJobFunction`, in order. - * @returns Triggered jobs array + * All jobs started via `startJobFunction`, in order. + * @returns Started jobs array */ - get triggeredJobs(): TriggeredJob[] { - return triggerJobFunction.mock.calls.map(([jobName, args, options]) => ({ + get startedJobs(): StartedJob[] { + return startJobFunction.mock.calls.map(([jobName, args, options]) => ({ jobName: jobName as string, args, ...(options !== undefined && { options: options as StartJobFunctionOptions }), @@ -352,12 +315,12 @@ export function mockWorkflow() { }, /** - * Configure what `triggerWorkflow` returns. Pass a string (same id every + * Configure what `startWorkflow` returns. Pass a string (same id every * call) or `(name, args, options) => string`. Default: a placeholder UUID. * @param handler - Static execution ID or a function returning one */ - setTriggerHandler(handler: string | TriggerHandlerFn): void { - triggerWorkflow.mockImplementation( + setStartHandler(handler: string | StartHandlerFn): void { + startWorkflow.mockImplementation( typeof handler === "function" ? async (name, args, options) => handler(name, args, options) : async () => handler, @@ -365,12 +328,12 @@ export function mockWorkflow() { }, /** - * Configure what `resumeWorkflow` returns. Pass a string (same id every - * call) or `(executionId) => string`. Default: echoes the input executionId. + * Configure what `resumeWorkflowExecution` returns. Pass a string (same id + * every call) or `(executionId) => string`. Default: echoes the input executionId. * @param handler - Static execution ID or a function returning one */ setResumeHandler(handler: string | ResumeHandlerFn): void { - resumeWorkflow.mockImplementation( + resumeWorkflowExecution.mockImplementation( typeof handler === "function" ? async (executionId) => handler(executionId) : async () => handler, @@ -391,7 +354,7 @@ export function mockWorkflow() { }) as SetWaitHandler, /** - * Set the `env` passed to job bodies invoked via `createWorkflowJob().trigger()`. + * Set the `env` passed to job bodies invoked via `createWorkflowJob().start()`. * Cleared on dispose / reset. * @param env - Env passed to job bodies. */ @@ -431,9 +394,9 @@ export function mockWorkflow() { /** Clear recorded calls while preserving configured responses. */ clear(): void { - triggerJobFunction.mockClear(); - triggerWorkflow.mockClear(); - resumeWorkflow.mockClear(); + startJobFunction.mockClear(); + startWorkflow.mockClear(); + resumeWorkflowExecution.mockClear(); wait.mockClear(); resolve.mockClear(); for (const mock of scopedMocks) mock.mockClear(); @@ -441,12 +404,12 @@ export function mockWorkflow() { /** Reset all workflow responses and recorded calls (keeps the mock installed). */ reset(): void { - triggerJobFunction.mockReset(); - triggerJobFunction.mockImplementation(defaultTriggerJob); - triggerWorkflow.mockReset(); - triggerWorkflow.mockImplementation(defaultTriggerWorkflow); - resumeWorkflow.mockReset(); - resumeWorkflow.mockImplementation(defaultResumeWorkflow); + startJobFunction.mockReset(); + startJobFunction.mockImplementation(defaultStartJob); + startWorkflow.mockReset(); + startWorkflow.mockImplementation(defaultStartWorkflow); + resumeWorkflowExecution.mockReset(); + resumeWorkflowExecution.mockImplementation(defaultResumeWorkflowExecution); wait.mockReset(); wait.mockImplementation(() => null); resolve.mockReset(); diff --git a/packages/sdk/src/vitest/workflow-local.ts b/packages/sdk/src/vitest/workflow-local.ts new file mode 100644 index 000000000..d2ba58164 --- /dev/null +++ b/packages/sdk/src/vitest/workflow-local.ts @@ -0,0 +1,284 @@ +/* oxlint-disable typescript/no-explicit-any */ +import { START_DEFAULT, getRegisteredJob } from "../configure/services/workflow/registry"; +import { + buildJobContext, + clearWorkflowTestEnv, + readWorkflowTestEnv, + writeWorkflowTestEnv, +} from "../configure/services/workflow/test-env-key"; +import { platformSerialize } from "../utils/test/platform-serialize"; +import type { Workflow, WorkflowJob } from "../configure/services/workflow"; +import type { TailorEnv } from "../runtime/types"; +import type { PlatformWorkflowAPI } from "../runtime/workflow"; + +type AnyWorkflowJob = WorkflowJob; +type AnyWorkflow = Workflow; + +type WorkflowInput = + W["mainJob"] extends WorkflowJob ? I : never; +type WorkflowOutput = + W["mainJob"] extends WorkflowJob ? Awaited : never; + +type GlobalWithTailor = { + tailor?: { + workflow?: PlatformWorkflowAPI; + }; +}; + +type StartRecord = { + jobName: string; + args: unknown; +} & ( + | { + status: "fulfilled"; + result: unknown; + } + | { + status: "rejected"; + error: unknown; + } +); + +interface LocalExecution { + records: StartRecord[]; + cursor: number; + pending?: PendingStart; +} + +class PendingStart { + constructor( + readonly jobName: string, + readonly args: unknown, + ) {} +} + +export interface RunWorkflowLocallyOptions { + /** Env passed to workflow job bodies during this local run. */ + env?: TailorEnv; +} + +/** + * Run a workflow's main job and dependent job starts locally with real job bodies. + * + * Use this for local full-chain workflow tests. Regular `.start()` calls + * delegate to the platform workflow runtime and should be mocked with + * `mockWorkflow()` when you are not intentionally running the local chain. + * @param workflow - Workflow definition to run + * @returns The main job output + */ +export function runWorkflowLocally>>( + workflow: W, +): Promise>; +/** + * Run a no-input workflow locally with optional runner settings. + * @param workflow - Workflow definition to run + * @param args - Must be `undefined` for no-input workflows + * @param options - Local runner options + * @returns The main job output + */ +export function runWorkflowLocally>>( + workflow: W, + args: undefined, + options?: RunWorkflowLocallyOptions, +): Promise>; +/** + * Run a workflow locally with real job bodies. + * @param workflow - Workflow definition to run + * @param args - Arguments passed to the workflow's main job + * @param options - Local runner options + * @returns The main job output + */ +export function runWorkflowLocally( + workflow: W, + args: WorkflowInput, + options?: RunWorkflowLocallyOptions, +): Promise>; +export async function runWorkflowLocally( + workflow: W, + args?: WorkflowInput, + options?: RunWorkflowLocallyOptions, +): Promise> { + const root = globalThis as unknown as GlobalWithTailor; + const previousTailor = root.tailor; + const previousEnv = readWorkflowTestEnv(); + const hasPreviousEnv = previousEnv !== undefined; + const runner = createLocalJobRunner(); + + if (options?.env !== undefined) { + writeWorkflowTestEnv({ ...options.env }); + } + + root.tailor = { + ...previousTailor, + workflow: createLocalWorkflowRuntime(previousTailor?.workflow, runner.startJobFunction), + }; + + try { + return (await runner.runJob(workflow.mainJob.name, args)) as WorkflowOutput; + } finally { + if (previousTailor) { + root.tailor = previousTailor; + } else { + delete root.tailor; + } + + if (options?.env !== undefined) { + if (hasPreviousEnv) { + writeWorkflowTestEnv(previousEnv); + } else { + clearWorkflowTestEnv(); + } + } + } +} + +function createLocalJobRunner(): { + runJob: (name: string, args?: unknown) => Promise; + startJobFunction: (name: string, args?: unknown) => unknown; +} { + let activeExecution: LocalExecution | undefined; + + const startJobFunction = (jobName: string, args?: unknown): unknown => { + if (!activeExecution) { + throw new Error( + `Cannot start workflow job "${jobName}" outside runWorkflowLocally() job execution.`, + ); + } + if (activeExecution.pending) { + throw activeExecution.pending; + } + + const serializedArgs = platformSerialize(args); + const index = activeExecution.cursor; + activeExecution.cursor += 1; + + const cached = activeExecution.records[index]; + if (cached) { + assertSameStart(cached, jobName, serializedArgs); + if (cached.status === "rejected") { + throw cached.error; + } + return platformSerialize(cached.result); + } + + const pending = new PendingStart(jobName, serializedArgs); + activeExecution.pending = pending; + throw pending; + }; + + const runJob = async (name: string, args?: unknown): Promise => { + const body = getRegisteredJob(name); + if (!body) { + return null; + } + + const records: StartRecord[] = []; + + for (;;) { + const execution: LocalExecution = { records, cursor: 0 }; + const previousExecution = activeExecution; + activeExecution = execution; + + try { + const out = await body(platformSerialize(args), buildJobContext()); + if (execution.pending) { + await settlePendingStart(records, execution.pending, runJob); + continue; + } + if (execution.cursor !== records.length) { + throw new Error( + `Workflow job start sequence changed while replaying "${name}". Expected ${records.length} start(s), but replay reached ${execution.cursor}.`, + ); + } + return platformSerialize(out); + } catch (cause) { + const pending = cause instanceof PendingStart ? cause : execution.pending; + if (pending) { + await settlePendingStart(records, pending, runJob); + continue; + } + throw cause; + } finally { + activeExecution = previousExecution; + } + } + }; + + return { runJob, startJobFunction }; +} + +async function settlePendingStart( + records: StartRecord[], + pending: PendingStart, + runJob: (name: string, args?: unknown) => Promise, +): Promise { + try { + records.push({ + jobName: pending.jobName, + args: pending.args, + status: "fulfilled", + result: await runJob(pending.jobName, pending.args), + }); + } catch (error) { + records.push({ + jobName: pending.jobName, + args: pending.args, + status: "rejected", + error, + }); + } +} + +function assertSameStart(record: StartRecord, jobName: string, args: unknown): void { + if (record.jobName === jobName && JSON.stringify(record.args) === JSON.stringify(args)) { + return; + } + + throw new Error( + `Workflow job start sequence changed while replaying. Expected ${record.jobName}(${JSON.stringify(record.args)}), but got ${jobName}(${JSON.stringify(args)}).`, + ); +} + +function createLocalWorkflowRuntime( + previous: PlatformWorkflowAPI | undefined, + startJobFunction: (name: string, args?: unknown) => unknown, +): PlatformWorkflowAPI { + const startWorkflow: PlatformWorkflowAPI["startWorkflow"] = async (name, args, options) => { + if (previous) { + return await previous.startWorkflow(name, args, options); + } + platformSerialize(args); + return START_DEFAULT; + }; + const resumeWorkflowExecution: PlatformWorkflowAPI["resumeWorkflowExecution"] = async ( + executionId, + ) => { + if (previous) { + return await previous.resumeWorkflowExecution(executionId); + } + return executionId; + }; + + return { + startJobFunction, + startWorkflow, + resumeWorkflowExecution, + wait: (key, payload) => { + if (previous) { + return previous.wait(key, payload); + } + throw new Error( + `No wait handler for "${key}". Acquire mockWorkflow() and call setWaitHandler(...).`, + ); + }, + resolve: async (executionId, key, callback) => { + if (previous) { + await previous.resolve(executionId, key, callback); + return; + } + throw new Error( + "No resolve handler. Acquire mockWorkflow() and call setResolveHandler(...).", + ); + }, + }; +} diff --git a/packages/sdk/src/vitest/workflow-runtime.ts b/packages/sdk/src/vitest/workflow-runtime.ts index 06d81fd83..28ef891a9 100644 --- a/packages/sdk/src/vitest/workflow-runtime.ts +++ b/packages/sdk/src/vitest/workflow-runtime.ts @@ -1,20 +1,14 @@ // Default `tailor.workflow` runner installed by the `tailor-runtime` environment. // Must stay free of `vitest` (`vi`): it loads via `./globals` in the environment // realm where `vi` is unavailable, hence relative imports only (no `@/` alias). -import { runRegisteredJob, runRegisteredWorkflow } from "../configure/services/workflow/registry"; +import { START_DEFAULT } from "../configure/services/workflow/registry"; +import { platformSerialize } from "../utils/test/platform-serialize"; import type { StartJobFunctionOptions, StartWorkflowOptions } from "../runtime/workflow"; export interface DefaultWorkflowRuntime { startJobFunction: (name: string, args?: unknown, options?: StartJobFunctionOptions) => unknown; - triggerJobFunction: (name: string, args?: unknown, options?: StartJobFunctionOptions) => unknown; startWorkflow: (name: string, args?: unknown, options?: StartWorkflowOptions) => Promise; - triggerWorkflow: ( - name: string, - args?: unknown, - options?: StartWorkflowOptions, - ) => Promise; resumeWorkflowExecution: (executionId: string) => Promise; - resumeWorkflow: (executionId: string) => Promise; wait: (key: string, payload?: unknown) => unknown; resolve: ( executionId: string, @@ -24,20 +18,22 @@ export interface DefaultWorkflowRuntime { } export function createDefaultWorkflowRuntime(): DefaultWorkflowRuntime { - const startJobFunction: DefaultWorkflowRuntime["startJobFunction"] = (name, args) => - runRegisteredJob(name, args); - const startWorkflow: DefaultWorkflowRuntime["startWorkflow"] = (name, args) => - runRegisteredWorkflow(name, args); + const startJobFunction: DefaultWorkflowRuntime["startJobFunction"] = (name) => { + throw new Error( + `No workflow job mock for "${name}". Acquire mockWorkflow() and call setJobHandler(...) or enqueueResult(...), or use runWorkflowLocally() for local workflow execution.`, + ); + }; + const startWorkflow: DefaultWorkflowRuntime["startWorkflow"] = async (_name, args) => { + platformSerialize(args); + return START_DEFAULT; + }; const resumeWorkflowExecution: DefaultWorkflowRuntime["resumeWorkflowExecution"] = async ( executionId, ) => executionId; return { startJobFunction, - triggerJobFunction: startJobFunction, startWorkflow, - triggerWorkflow: startWorkflow, resumeWorkflowExecution, - resumeWorkflow: resumeWorkflowExecution, wait: (key: string): unknown => { throw new Error( `No wait handler for "${key}". Acquire mockWorkflow() and call setWaitHandler(...).`, diff --git a/packages/sdk/tsdown.config.ts b/packages/sdk/tsdown.config.ts index 33abaa6a9..4e4af08f3 100644 --- a/packages/sdk/tsdown.config.ts +++ b/packages/sdk/tsdown.config.ts @@ -1,50 +1,10 @@ -import { cpSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync } from "node:fs"; import path from "node:path"; import Sonda from "sonda/rolldown"; import { defineConfig, type TsdownPluginOption } from "tsdown"; import { entry } from "./scripts/build-entries.mjs"; import { loadYamlText } from "./scripts/yaml-text-plugin.mjs"; -const runtimeGlobalsBanner = '/// '; -const runtimeGlobalsBannerPattern = - /^\/\/\/ \r?\n/; - -function copyErdViewerAssets(outDir: string): void { - const source = path.resolve("src/cli/commands/tailordb/erd/viewer-assets"); - const target = path.resolve(outDir, "cli/erd-viewer-assets"); - rmSync(target, { recursive: true, force: true }); - cpSync(source, target, { recursive: true }); -} - -function copyTsconfigPathsHook(outDir: string): void { - cpSync( - path.resolve("src/cli/tsconfig-paths-hook.mjs"), - path.join(outDir, "cli/tsconfig-paths-hook.mjs"), - ); - cpSync( - path.resolve("src/cli/tsconfig-paths-hook.d.mts"), - path.join(outDir, "cli/tsconfig-paths-hook.d.mts"), - ); -} - -function stripBannerExceptConfigureEntry(outDir: string): void { - const root = path.resolve(outDir); - const keep = path.join(root, "configure", "index.d.mts"); - const walk = (current: string): void => { - for (const dirent of readdirSync(current, { withFileTypes: true })) { - const full = path.join(current, dirent.name); - if (dirent.isDirectory()) { - walk(full); - } else if (dirent.isFile() && dirent.name.endsWith(".d.mts") && full !== keep) { - const content = readFileSync(full, "utf-8"); - const cleaned = content.replace(runtimeGlobalsBannerPattern, ""); - if (cleaned !== content) writeFileSync(full, cleaned, "utf-8"); - } - } - }; - walk(root); -} - function yamlText() { return { name: "yaml-text", @@ -94,13 +54,14 @@ export default defineConfig([ sourcemap: true, // peer dependencies: prevent bundling, resolve at runtime. // `@tailor-platform/sdk` (self-name) is kept external so subpath entries can reference - // types like `ConnectionName` from the main entry instead of inlining them, letting a - // single `declare module "@tailor-platform/sdk"` augmentation narrow every entry point. + // types like `ConnectionName`/`MachineUserName` from the main entry instead of inlining + // them, letting a single `declare module "@tailor-platform/sdk"` augmentation narrow + // every entry point. deps: { neverBundle: externalDeps }, plugins: jsPlugins, onSuccess: (config) => { - copyErdViewerAssets(config.outDir); - copyTsconfigPathsHook(config.outDir); + cpSync(path.resolve("src/cli/ts-hook.mjs"), path.join(config.outDir, "cli/ts-hook.mjs")); + cpSync(path.resolve("src/cli/ts-hook.d.mts"), path.join(config.outDir, "cli/ts-hook.d.mts")); }, }, { @@ -111,12 +72,6 @@ export default defineConfig([ }, unbundle: true, root: "src", - banner: { - dts: runtimeGlobalsBanner, - }, deps: { neverBundle: externalDeps }, - onSuccess: (config) => { - stripBannerExceptConfigureEntry(config.outDir); - }, }, ]); diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts index 98fc3b9bf..35d8714ab 100644 --- a/packages/sdk/vitest.config.ts +++ b/packages/sdk/vitest.config.ts @@ -43,6 +43,13 @@ const integrationTestIncludes = [ "src/plugin/compat.test.ts", ]; +// The CLI plugin test exercises Windows-specific PATHEXT/`.cmd`/`.ps1` spawn +// branches, so it gets its own project ("unit-plugin", which the `unit*` glob +// still picks up on Linux) that a Windows CI job can run via `--project +// unit-plugin` — no separator-sensitive path filter needed. Excluded from the +// general unit split below so it does not run twice. +const pluginTestInclude = "src/cli/shared/plugin.test.ts"; + // Split unit tests by whether they mutate worker-global state. With // `isolate: false` a worker shares one module registry and one global object // across files, so per-file partial module mocks (e.g. `vi.mock("node:fs", ...)`) @@ -57,6 +64,8 @@ const classifyUnitTests = (): { isolated: string[]; shared: string[] } => { file.includes("/node_modules/") || file.includes("/__test_fixtures__/") || integrationTestFiles.has(file) || + // Carved into its own "unit-plugin" project (see below). + file === pluginTestInclude || // Self-contained nested vitest project with its own config. file.startsWith("src/vitest/integration/"); @@ -117,6 +126,16 @@ export default defineConfig({ include: sharedUnitTests, }, }, + { + extends: true, + test: { + // Carved out so a Windows CI job can run just the plugin test via + // `--project unit-plugin`; the `unit*` glob still runs it on Linux. + name: "unit-plugin", + include: [pluginTestInclude], + typecheck: { enabled: false }, + }, + }, { extends: true, test: { @@ -131,7 +150,7 @@ export default defineConfig({ name: "e2e", include: ["e2e/**/*.test.ts"], testTimeout: 120000, - hookTimeout: 120000, + hookTimeout: 300000, globalSetup: ["e2e/globalSetup.ts"], }, }, diff --git a/packages/tailor-proto/src/tailor/v1/http_adapter_resource_pb.js b/packages/tailor-proto/src/tailor/v1/http_adapter_resource_pb.js index 91f97ff03..f415ca6cf 100644 --- a/packages/tailor-proto/src/tailor/v1/http_adapter_resource_pb.js +++ b/packages/tailor-proto/src/tailor/v1/http_adapter_resource_pb.js @@ -19,4 +19,3 @@ export const file_tailor_v1_http_adapter_resource = /*@__PURE__*/ */ export const HttpAdapterSchema = /*@__PURE__*/ messageDesc(file_tailor_v1_http_adapter_resource, 0); - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e937e1d5b..0242ed20b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@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) knip: specifier: 6.26.0 version: 6.26.0 @@ -72,12 +75,18 @@ 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-tailordb-erd': + specifier: workspace:^ + version: link:../packages/sdk-plugin-tailordb-erd '@types/node': specifier: 24.13.3 version: 24.13.3 '@typescript/native-preview': specifier: 7.0.0-dev.20260707.2 version: 7.0.0-dev.20260707.2 + 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) graphql: specifier: 17.0.2 version: 17.0.2 @@ -107,13 +116,16 @@ importers: 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.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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)) llm-challenge: devDependencies: '@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) @@ -128,7 +140,7 @@ importers: 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.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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/create-sdk: dependencies: @@ -154,6 +166,9 @@ importers: '@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) @@ -192,7 +207,7 @@ importers: 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.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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/create-sdk/templates/generators: devDependencies: @@ -219,7 +234,7 @@ importers: 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.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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/create-sdk/templates/hello-world: devDependencies: @@ -318,7 +333,7 @@ importers: 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.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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/create-sdk/templates/static-web-site: devDependencies: @@ -369,7 +384,7 @@ importers: 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.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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/create-sdk/templates/workflow: devDependencies: @@ -402,7 +417,7 @@ importers: 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.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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/eslint-plugin-sdk: dependencies: @@ -421,7 +436,7 @@ importers: 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.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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: dependencies: @@ -488,12 +503,12 @@ importers: '@urql/core': specifier: 6.0.3 version: 6.0.3(graphql@17.0.2) + amaro: + specifier: 1.1.10 + version: 1.1.10 chalk: specifier: 5.6.2 version: 5.6.2 - chokidar: - specifier: 5.0.0 - version: 5.0.0 confbox: specifier: 0.2.4 version: 0.2.4 @@ -506,9 +521,6 @@ importers: find-up-simple: specifier: 1.0.1 version: 1.0.1 - get-tsconfig: - specifier: 4.14.0 - version: 4.14.0 globals: specifier: 17.7.0 version: 17.7.0 @@ -521,9 +533,6 @@ importers: kysely: specifier: 0.29.3 version: 0.29.3 - madge: - specifier: 8.0.0 - version: 8.0.0(typescript@6.0.3) mime-types: specifier: 3.0.2 version: 3.0.2 @@ -566,9 +575,6 @@ importers: ts-cron-validator: specifier: 1.1.5 version: 1.1.5 - tsx: - specifier: 4.23.1 - version: 4.23.1 type-fest: specifier: 5.8.0 version: 5.8.0 @@ -577,7 +583,7 @@ importers: version: 8.7.0 vite: specifier: ^6.0.0 || ^7.0.0 || ^8.0.0 - version: 8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + version: 8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) xdg-basedir: specifier: 5.1.0 version: 5.1.0 @@ -591,9 +597,6 @@ importers: '@tailor-platform/tailor-proto': specifier: workspace:^ version: link:../tailor-proto - '@types/madge': - specifier: 5.0.3 - version: 5.0.3 '@types/mime-types': specifier: 3.0.1 version: 3.0.1 @@ -609,6 +612,9 @@ importers: '@vitest/coverage-v8': specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) + 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) oxfmt: specifier: 0.58.0 version: 0.58.0 @@ -629,7 +635,7 @@ importers: 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.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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)) zinfer: specifier: 0.2.5 version: 0.2.5(typescript@6.0.3)(zod@4.4.3) @@ -673,6 +679,9 @@ importers: '@types/semver': specifier: 7.7.1 version: 7.7.1 + 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) @@ -684,7 +693,59 @@ importers: 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.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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: + specifier: 5.6.2 + version: 5.6.2 + chokidar: + specifier: 5.0.0 + version: 5.0.0 + mime-types: + specifier: 3.0.2 + version: 3.0.2 + open: + specifier: 11.0.0 + version: 11.0.0 + 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/mime-types': + specifier: 3.0.1 + version: 3.0.1 + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + eslint-plugin-zod: + specifier: 4.7.0 + version: 4.7.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.73.0(oxlint-tsgolint@0.24.0))(typescript@6.0.3)(zod@4.4.3) + oxlint: + specifier: 1.73.0 + version: 1.73.0(oxlint-tsgolint@0.24.0) + tsdown: + specifier: 0.22.5 + version: 0.22.5(@typescript/native-preview@7.0.0-dev.20260707.2)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.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/tailor-proto: dependencies: @@ -907,18 +968,27 @@ packages: resolution: {integrity: sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==} engines: {node: '>=14.17.0'} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.0': resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.0': resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -1088,18 +1158,34 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint-zod/utils@2.3.0': + resolution: {integrity: sha512-6XEQLA5lDOOLnX05bgzDT3DyxlCGgLDFX3q0wYSSUSyVJj5WD+8wdlIAFY8kjatSDHWJQ+qFI/BdiDh9o50nBQ==} + engines: {node: ^20 || ^22 || >=24} + '@eslint/config-array@0.21.2': resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/config-helpers@0.4.2': resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/core@0.17.0': resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/eslintrc@3.3.6': resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1112,10 +1198,18 @@ packages: resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/plugin-kit@0.4.1': resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@graphql-typed-document-node/core@3.2.0': resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} peerDependencies: @@ -1722,6 +1816,9 @@ packages: cpu: [x64] os: [win32] + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.137.0': resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} @@ -2109,43 +2206,80 @@ packages: resolution: {integrity: sha512-IfhLdXMjNaAt2Z/r0hAqMvH4UN/Eb6LAbnSHqF9Ay1EhQId8rFX58yIWXLUit6VjJTJAptnSPtO8nSu2/ggQsg==} engines: {node: '>=22.13'} - '@publint/pack@0.1.5': - resolution: {integrity: sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw==} + '@publint/pack@0.1.4': + resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=18'} '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-arm64@1.1.5': resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.1.5': resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-freebsd-x64@1.1.5': resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.1.5': resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2153,6 +2287,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.1.5': resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2160,6 +2301,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.1.5': resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2167,6 +2315,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.1.5': resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2174,6 +2329,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.1.5': resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2181,6 +2343,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-x64-musl@1.1.5': resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2188,23 +2357,46 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.1.5': resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.1.5': resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.1.5': resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.1.5': resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2268,15 +2460,15 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/madge@5.0.3': - resolution: {integrity: sha512-NlQJd0qRAoyu+pawTDhLxkW940QT2dqASfwd2g/xEZu2F4Xjwa7TVRSPdbmZwUF1ygvAh0/nepeN7JjwEuOXCA==} - '@types/mime-types@3.0.1': resolution: {integrity: sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==} @@ -2289,30 +2481,67 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@typescript-eslint/project-service@8.62.1': - resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==} + '@typescript-eslint/project-service@8.61.0': + resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.62.0': + resolution: {integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.62.0': + resolution: {integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.61.0': + resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/tsconfig-utils@8.62.0': + resolution: {integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.62.1': - resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==} + '@typescript-eslint/types@8.61.0': + resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.62.0': + resolution: {integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.61.0': + resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.62.1': - resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==} + '@typescript-eslint/typescript-estree@8.62.0': + resolution: {integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.62.1': - resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==} + '@typescript-eslint/utils@8.62.0': + resolution: {integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.62.1': - resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} + '@typescript-eslint/visitor-keys@8.61.0': + resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.62.0': + resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': @@ -2403,140 +2632,262 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} - '@vue/compiler-core@3.5.39': - resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + '@vue/compiler-core@3.5.38': + resolution: {integrity: sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==} - '@vue/compiler-dom@3.5.39': - resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + '@vue/compiler-dom@3.5.38': + resolution: {integrity: sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==} - '@vue/compiler-sfc@3.5.39': - resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + '@vue/compiler-sfc@3.5.38': + resolution: {integrity: sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==} - '@vue/compiler-ssr@3.5.39': - resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + '@vue/compiler-ssr@3.5.38': + resolution: {integrity: sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==} - '@vue/shared@3.5.39': - resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + '@vue/shared@3.5.38': + resolution: {integrity: sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==} - '@yuku-codegen/binding-darwin-arm64@0.6.1': - resolution: {integrity: sha512-LDJtpOKtcv9f3V0eDUwFmmy47t2VC+DAuN+gq80R1IA+fa0d408i6sHsVtt6n+g5rf8f86ySoPSAe94lHt6Ixw==} + '@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-x64@0.6.1': - resolution: {integrity: sha512-fBwpBOh33W7N87F94SVNExwm2KUV3ROhk51okr3Oy2ost1/JJuBWINVjcgwd2WPKZEFzUXgCzj/03UR/G+WIrQ==} + '@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.6.1': - resolution: {integrity: sha512-UpMkskQV3a5oPnJV+GFFupIqnLwSBD4ZsZAVUZuNVkrqct433FHKqTwuG+P5JhHZbmhf+++3Ie/V2sgduyXrAQ==} + '@yuku-codegen/binding-freebsd-x64@0.5.48': + resolution: {integrity: sha512-CA0AQAEApDkbw51PdLWMtKPJ41/7rvXsS3SJs+phG7fHJI+MuFzWuLbkucZfZoEOiDscmcsfYIdgL8BsfuyKKQ==} cpu: [x64] os: [freebsd] - '@yuku-codegen/binding-linux-arm-gnu@0.6.1': - resolution: {integrity: sha512-HICjDelfEDeD6TD8OEz/Dvt8KHxJiETR+paI/Fr7eVTQbjMfRrXJz8O1qV1qGH5SHZUGl2SAw2Rp+MLtXOjCrQ==} + '@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.6.1': - resolution: {integrity: sha512-pUswnwa+WOmtH2ZGOWL05kFLMNY7/TnEAryfIv1yVFzKQnmSy9TKYi3oIOxGZL3w+cdUKCZ6Q+jaD0oI10ztzA==} + '@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.6.1': - resolution: {integrity: sha512-Zro0FOu9clLCmqCnUKzEWHAu30tss0iEfhs+KDXm9Dpm1FkIHAKu43tF6FQU2hsTA7a8xd93NGddzc2EOJFKUg==} + '@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-musl@0.6.1': - resolution: {integrity: sha512-NZaT+mp9toqWuFEA4MYW5HMRxgIa8DCqnTTnM5SrrojZgm4QoMI/mJfifVet1ZHgl/Dly5m6H6GPpq43uXVj8g==} + '@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.6.1': - resolution: {integrity: sha512-M5macseSCBPvJ4yfKNyQpMc7nBQBtj39MNfMt0r+8UkTnR5qJE00JJx06puHgPxT5hnGxMAuAWZ+3a9H2ngqAw==} + '@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.6.1': - resolution: {integrity: sha512-liAyBZI5AbazZGeeNfWj0jCD/TE2L84hgVYh4KkjJA/N9bNzFQCDf4BvWP76nEO89r2tIGEUjbXdM4mM26riHg==} + '@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-win32-arm64@0.6.1': - resolution: {integrity: sha512-qItzfH3x6MYChPeGfvh22rHD92WLgXQRSuwvspRVSnLvpnubEfZd+9REPRQVT2l9fIuETDCEkDNRqkDROQTkgA==} + '@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.6.1': - resolution: {integrity: sha512-DFFkKROZ9ZAHmFMUFRtRTkosZds1KH8BOx5t3UpNULIjT3iuUmEx9V5pWR0xOi66sANY38Ap77nz1kOZraQxEg==} + '@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.6.1': - resolution: {integrity: sha512-jORysyRZg5zGDgVw15LGMsjZDh7jwjpUIJRBHgFt0ir15O5pEazfvuF2dnwvrJiTF0IT1EgHAVbTAYJWwQLCjg==} + '@yuku-parser/binding-darwin-arm64@0.5.48': + resolution: {integrity: sha512-If8mb7HH3vqghJ2NNZ8SuHfhsnjVzOxJpB8xcNOXS5WjYrs2mUhHIh5KOIvK13hDOzh0htGeGK3A6MsiEqE7HQ==} cpu: [arm64] os: [darwin] - '@yuku-parser/binding-darwin-x64@0.6.1': - resolution: {integrity: sha512-dTeYFkkFlbP/WCB2DtezXas3NApOPtFlXSdssB7wGtY9wpNp4HAkVo1KBwI5mcHK0e2joyUcqTSf44mFE+q7vg==} + '@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-freebsd-x64@0.6.1': - resolution: {integrity: sha512-GExDp3rebo28mt3EAjvKQs0ZC3gkznAErV0I9TUNDa9muuhvD35kft61Mpsc6+NWeE+BG+kUyKbm6iO5B6ZMMA==} + '@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.6.1': - resolution: {integrity: sha512-a9MjABj4J0VE3Z2oROGhmeddZZrhwwrnl4ZWZOuHUhD/smDtDiNtr0LpCbKB7rEYaQ29snopOPdZ/0T3YgLglQ==} + '@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-musl@0.6.1': - resolution: {integrity: sha512-ctuvXJgDRKKlmJfHxT4RsTvcAcHEFNJHTCsGbtt4rluQpVDc+ezk9JvQ534ehoIfZ9T0eIHSBgqYAZ4xCatNmQ==} + '@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-arm64-gnu@0.6.1': - resolution: {integrity: sha512-vRtyoTtT0Ltowuh9LWOl/ZNU9h79J89ilOz5SEGspcw0jfhoUt19i07VNitll4jjfg5p2EN00q+MqX0pAobrFw==} + '@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.6.1': - resolution: {integrity: sha512-vCsc3GOe1ylmRyfo/WLjIhjiCmaTtJbWNF4ZtgjNegDjpsRsFuCcP9duXB41QcfnJK38repKVFqFh0LR3l48FA==} + '@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-x64-gnu@0.6.1': - resolution: {integrity: sha512-gw3d81RdUHSYwjDW2IG6gEtm4VDoPP4ZOqpuC6Nc8+UBfos+4gTWOgzmuxIOVhkSV2fJCcUDpSJIlPzEU0FLZw==} + '@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-musl@0.6.1': - resolution: {integrity: sha512-nzU+Doq9UgZvYYvald36lZJ2Neeyheije6WE/YpoFt/oJiNmnjArRgr2CMtb/7gWBl80YSMwcHK4Ju0E+7wfWg==} + '@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.6.1': - resolution: {integrity: sha512-r3tXFVDliWCPe7TL6DVxUkT4rkqnXyeFVSEDf+V9My6Gztq99/gIe3POQqFbshTRuSrpEYMGMbGxeFh+m+stxA==} + '@yuku-parser/binding-win32-arm64@0.5.48': + resolution: {integrity: sha512-w6cQQLbqj3Jcom5Q7ifm103NUOQ9d+Cb4VU5lkrZDjMnwVJ9Hzzg1vCQR7miJuF44vhCXldbme5UryE3giEKlA==} cpu: [arm64] os: [win32] - '@yuku-parser/binding-win32-x64@0.6.1': - resolution: {integrity: sha512-FqYMOqeCS2XTdn5yvaKlOhtSQ84mVO3aTXp6LGfMd9Zq8RsV4H8qLWv+sxJsgPCXfuBV64u8/f+CTr3uIwNLWA==} + '@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] os: [win32] @@ -2612,11 +2963,11 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} buffer@5.7.1: @@ -2646,8 +2997,8 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chardet@2.2.0: - resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} @@ -2825,8 +3176,8 @@ packages: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} - enhanced-resolve@5.24.1: - resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==} + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} engines: {node: '>=10.13.0'} entities@7.0.1: @@ -2837,8 +3188,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.3.0: - resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -2857,10 +3208,29 @@ packages: engines: {node: '>=6.0'} hasBin: true + eslint-plugin-zod@4.7.0: + resolution: {integrity: sha512-unziYvD8FGxhfL6rYuC3Cjdx/K3yMWSTv6t5uB9KEgzMajDXuQcp67/WDxdUiEFhGY1LlXJs03xQc+Pg6+vqrw==} + engines: {node: ^20 || ^22 || >=24} + peerDependencies: + eslint: ^9 || ^10 + oxlint: ^1.59.0 + zod: ^4 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + zod: + optional: true + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2873,6 +3243,16 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2887,6 +3267,10 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -2918,12 +3302,12 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - expect-type@1.4.0: - resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - exsolve@1.1.0: - resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2940,8 +3324,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.3: - resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -3462,8 +3846,8 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3555,8 +3939,8 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-manager-detector@1.7.0: - resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} @@ -3636,8 +4020,8 @@ packages: peerDependencies: postcss: ^8.2.9 - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: @@ -3732,6 +4116,25 @@ 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} @@ -3751,6 +4154,11 @@ packages: vue-tsc: optional: true + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3788,8 +4196,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.9.0: - resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} siginfo@2.0.0: @@ -3809,8 +4217,8 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} - smol-toml@1.7.0: - resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} sonda@0.14.0: @@ -3945,6 +4353,40 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} + tsdown@0.22.5: + resolution: {integrity: sha512-0iEpnuOVBGo7mPW+FlyglErHBExYOAlpLBlCl/QcTrRUJ+TF2prfzl6ipQS5VsjJejFXnmbkdh0Ta53B3stWxg==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.5 + '@tsdown/exe': 0.22.5 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + tsdown@0.22.7: resolution: {integrity: sha512-4egbOzc9dxVv/QS+gDV75FIxDIjQQeOnXBlUuikyjmn0ozuc6FW11djJjEEo3vqkuJRygpnKHurnj+Iwftk4VA==} engines: {node: ^22.18.0 || >=24.11.0} @@ -4005,8 +4447,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - unbash@4.0.2: - resolution: {integrity: sha512-8gwNZ29+0/3zmXw7ToIHZtg6wK37xnniRUdBt7B27xZxaxfgR5tGMaGHT0t0dLtBV9fXE7zurh0s6Z1DHVjfWg==} + unbash@4.0.1: + resolution: {integrity: sha512-1ajSo3813sDoVIHx4inJdUS4l5L2ic5cFiddemPiyjb/PZEoBAhFwHtbaEdRDFxbAKy7FCG7s5ww3/uCFawuIA==} engines: {node: '>=14'} unconfig-core@7.5.0: @@ -4029,13 +4471,13 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite@8.1.3: - resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.1.18 esbuild: 0.28.1 jiti: '>=1.21.0' less: ^4.0.0 @@ -4169,11 +4611,17 @@ packages: yuku-ast@0.1.7: resolution: {integrity: sha512-2RiMEWv500TixY5rJy6OZd4fSy9WYZKWh6gGbIJ7y7vAGcuCugWOWwOLGaQcRZrXcPUfqtLtvpaJ3SdXtWlhKA==} - yuku-codegen@0.6.1: - resolution: {integrity: sha512-6RJqqON2xYhMEp/sZv5oOSI3uOpWwRwzAi2fc/rMcRFjcqedAC5Fyp4AD9Vn2b8SB7hf9ESqVW+YwbDs/KvyKA==} + yuku-codegen@0.5.48: + resolution: {integrity: sha512-p7HxD5Xl4jzDzqMrGePAOeSHmRY4g58h4HuGq15weQFPxuPWd/W6e7nqp/+Lea6JfpOdBwJOAyXFqIZ/J9Zfnw==} - yuku-parser@0.6.1: - resolution: {integrity: sha512-dPE3/+H2VBw9LhjoIVeW/axKidYGd+XzNtrwGGseZ0325cQFl0Dpwyh0R74XWe/WqQn4M8CR5YApsv2KF2zN1A==} + 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==} zinfer@0.2.5: resolution: {integrity: sha512-SQC0tsLjw4FJvCzL3V0GI1SGNZVR9p/8bFIG0NsUIwbpbOUysRXRBLeTvxruHgDTfNJOObMG2/nfXaMGZn8hHw==} @@ -4326,7 +4774,7 @@ snapshots: '@changesets/format@0.1.0': dependencies: - package-manager-detector: 1.7.0 + package-manager-detector: 1.6.0 '@changesets/get-dependents-graph@3.0.0-next.6': dependencies: @@ -4402,6 +4850,12 @@ snapshots: '@discoveryjs/json-ext@1.1.0': {} + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.11.0': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -4414,6 +4868,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.0': dependencies: tslib: 2.8.1 @@ -4424,6 +4883,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -4507,6 +4971,11 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0))': + dependencies: + eslint: 10.5.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))': dependencies: eslint: 9.39.5(jiti@2.7.0) @@ -4514,6 +4983,15 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} + '@eslint-zod/utils@2.3.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + esquery: 1.7.0 + transitivePeerDependencies: + - eslint + - supports-color + - typescript + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 @@ -4522,14 +5000,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + '@eslint/config-helpers@0.4.2': dependencies: '@eslint/core': 0.17.0 + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + '@eslint/core@0.17.0': dependencies: '@types/json-schema': 7.0.15 + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/eslintrc@3.3.6': dependencies: ajv: 6.15.0 @@ -4548,11 +5042,18 @@ snapshots: '@eslint/object-schema@2.1.7': {} + '@eslint/object-schema@3.0.5': {} + '@eslint/plugin-kit@0.4.1': dependencies: '@eslint/core': 0.17.0 levn: 0.4.1 + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + '@graphql-typed-document-node/core@3.2.0(graphql@17.0.2)': dependencies: graphql: 17.0.2 @@ -4620,7 +5121,7 @@ snapshots: '@inquirer/external-editor@3.0.3(@types/node@24.13.3)': dependencies: - chardet: 2.2.0 + chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: '@types/node': 24.13.3 @@ -4777,6 +5278,13 @@ snapshots: '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: '@emnapi/core': 1.11.0 @@ -5003,6 +5511,8 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.139.0': optional: true + '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.137.0': {} '@oxc-project/types@0.139.0': {} @@ -5202,50 +5712,91 @@ snapshots: '@pnpm/deps.graph-sequencer@1100.0.0': {} - '@publint/pack@0.1.5': - dependencies: - tinyexec: 1.2.4 + '@publint/pack@0.1.4': {} '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 + '@rolldown/binding-android-arm64@1.0.3': + optional: true + '@rolldown/binding-android-arm64@1.1.5': optional: true + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + '@rolldown/binding-darwin-arm64@1.1.5': optional: true + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + '@rolldown/binding-darwin-x64@1.1.5': optional: true + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + '@rolldown/binding-freebsd-x64@1.1.5': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + '@rolldown/binding-linux-x64-musl@1.1.5': optional: true + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + '@rolldown/binding-openharmony-arm64@1.1.5': optional: true + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: '@emnapi/core': 1.11.1 @@ -5253,9 +5804,15 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + '@rolldown/binding-win32-arm64-msvc@1.1.5': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true @@ -5316,14 +5873,12 @@ snapshots: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} - '@types/madge@5.0.3': - dependencies: - '@types/node': 24.13.3 - '@types/mime-types@3.0.1': {} '@types/node@24.13.3': @@ -5334,27 +5889,47 @@ snapshots: '@types/semver@7.7.1': {} - '@typescript-eslint/project-service@8.62.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@5.9.3) - '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/tsconfig-utils@8.62.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.62.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@6.0.3) + '@typescript-eslint/types': 8.62.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.62.0': + dependencies: + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/types@8.62.1': {} + '@typescript-eslint/tsconfig-utils@8.62.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/types@8.61.0': {} - '@typescript-eslint/typescript-estree@8.62.1(typescript@5.9.3)': + '@typescript-eslint/types@8.62.0': {} + + '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.62.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@5.9.3) - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/visitor-keys': 8.62.1 + '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.5 @@ -5364,9 +5939,40 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.62.1': + '@typescript-eslint/typescript-estree@8.62.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.62.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@6.0.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.62.0': dependencies: - '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/types': 8.62.0 eslint-visitor-keys: 5.0.1 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': @@ -5419,7 +6025,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 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)) '@vitest/expect@4.1.10': dependencies: @@ -5430,13 +6036,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + '@vitest/mocker@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))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + 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) '@vitest/pretty-format@4.1.10': dependencies: @@ -5462,102 +6068,168 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@vue/compiler-core@3.5.39': + '@vue/compiler-core@3.5.38': dependencies: '@babel/parser': 7.29.7 - '@vue/shared': 3.5.39 + '@vue/shared': 3.5.38 entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.39': + '@vue/compiler-dom@3.5.38': dependencies: - '@vue/compiler-core': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/compiler-core': 3.5.38 + '@vue/shared': 3.5.38 - '@vue/compiler-sfc@3.5.39': + '@vue/compiler-sfc@3.5.38': dependencies: '@babel/parser': 7.29.7 - '@vue/compiler-core': 3.5.39 - '@vue/compiler-dom': 3.5.39 - '@vue/compiler-ssr': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/compiler-core': 3.5.38 + '@vue/compiler-dom': 3.5.38 + '@vue/compiler-ssr': 3.5.38 + '@vue/shared': 3.5.38 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.16 + postcss: 8.5.15 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.39': + '@vue/compiler-ssr@3.5.38': dependencies: - '@vue/compiler-dom': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/compiler-dom': 3.5.38 + '@vue/shared': 3.5.38 + + '@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 - '@vue/shared@3.5.39': {} + '@yuku-codegen/binding-win32-x64@0.6.3': + optional: true - '@yuku-codegen/binding-darwin-arm64@0.6.1': + '@yuku-parser/binding-darwin-arm64@0.5.48': optional: true - '@yuku-codegen/binding-darwin-x64@0.6.1': + '@yuku-parser/binding-darwin-arm64@0.6.3': optional: true - '@yuku-codegen/binding-freebsd-x64@0.6.1': + '@yuku-parser/binding-darwin-x64@0.5.48': optional: true - '@yuku-codegen/binding-linux-arm-gnu@0.6.1': + '@yuku-parser/binding-darwin-x64@0.6.3': optional: true - '@yuku-codegen/binding-linux-arm-musl@0.6.1': + '@yuku-parser/binding-freebsd-x64@0.5.48': optional: true - '@yuku-codegen/binding-linux-arm64-gnu@0.6.1': + '@yuku-parser/binding-freebsd-x64@0.6.3': optional: true - '@yuku-codegen/binding-linux-arm64-musl@0.6.1': + '@yuku-parser/binding-linux-arm-gnu@0.5.48': optional: true - '@yuku-codegen/binding-linux-x64-gnu@0.6.1': + '@yuku-parser/binding-linux-arm-gnu@0.6.3': optional: true - '@yuku-codegen/binding-linux-x64-musl@0.6.1': + '@yuku-parser/binding-linux-arm-musl@0.5.48': optional: true - '@yuku-codegen/binding-win32-arm64@0.6.1': + '@yuku-parser/binding-linux-arm-musl@0.6.3': optional: true - '@yuku-codegen/binding-win32-x64@0.6.1': + '@yuku-parser/binding-linux-arm64-gnu@0.5.48': optional: true - '@yuku-parser/binding-darwin-arm64@0.6.1': + '@yuku-parser/binding-linux-arm64-gnu@0.6.3': optional: true - '@yuku-parser/binding-darwin-x64@0.6.1': + '@yuku-parser/binding-linux-arm64-musl@0.5.48': optional: true - '@yuku-parser/binding-freebsd-x64@0.6.1': + '@yuku-parser/binding-linux-arm64-musl@0.6.3': optional: true - '@yuku-parser/binding-linux-arm-gnu@0.6.1': + '@yuku-parser/binding-linux-x64-gnu@0.5.48': optional: true - '@yuku-parser/binding-linux-arm-musl@0.6.1': + '@yuku-parser/binding-linux-x64-gnu@0.6.3': optional: true - '@yuku-parser/binding-linux-arm64-gnu@0.6.1': + '@yuku-parser/binding-linux-x64-musl@0.5.48': optional: true - '@yuku-parser/binding-linux-arm64-musl@0.6.1': + '@yuku-parser/binding-linux-x64-musl@0.6.3': optional: true - '@yuku-parser/binding-linux-x64-gnu@0.6.1': + '@yuku-parser/binding-win32-arm64@0.5.48': optional: true - '@yuku-parser/binding-linux-x64-musl@0.6.1': + '@yuku-parser/binding-win32-arm64@0.6.3': optional: true - '@yuku-parser/binding-win32-arm64@0.6.1': + '@yuku-parser/binding-win32-x64@0.5.48': optional: true - '@yuku-parser/binding-win32-x64@0.6.1': + '@yuku-parser/binding-win32-x64@0.6.3': optional: true '@yuku-toolchain/types@0.5.43': {} @@ -5578,7 +6250,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.3 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -5622,12 +6294,12 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - brace-expansion@1.1.15: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.7: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -5653,7 +6325,7 @@ snapshots: chalk@5.6.2: {} - chardet@2.2.0: {} + chardet@2.1.1: {} chokidar@5.0.0: dependencies: @@ -5754,11 +6426,11 @@ snapshots: dependencies: node-source-walk: 7.0.2 - detective-postcss@8.0.4(postcss@8.5.16): + detective-postcss@8.0.4(postcss@8.5.15): dependencies: is-url-superb: 4.0.0 - postcss: 8.5.16 - postcss-values-parser: 6.0.2(postcss@8.5.16) + postcss: 8.5.15 + postcss-values-parser: 6.0.2(postcss@8.5.15) detective-sass@6.0.2: dependencies: @@ -5774,7 +6446,7 @@ snapshots: detective-typescript@14.1.2(typescript@5.9.3): dependencies: - '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) ast-module-types: 6.0.2 node-source-walk: 7.0.2 typescript: 5.9.3 @@ -5784,7 +6456,7 @@ snapshots: detective-vue2@2.3.0(typescript@5.9.3): dependencies: '@dependents/detective-less': 5.0.3 - '@vue/compiler-sfc': 3.5.39 + '@vue/compiler-sfc': 3.5.38 detective-es6: 5.0.2 detective-sass: 6.0.2 detective-scss: 5.0.2 @@ -5806,7 +6478,7 @@ snapshots: empathic@2.0.1: {} - enhanced-resolve@5.24.1: + enhanced-resolve@5.24.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -5815,7 +6487,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.3.0: {} + es-module-lexer@2.1.0: {} es-toolkit@1.49.0: {} @@ -5858,17 +6530,73 @@ snapshots: optionalDependencies: source-map: 0.6.1 + eslint-plugin-zod@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): + dependencies: + '@eslint-zod/utils': 2.3.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + optionalDependencies: + eslint: 10.5.0(jiti@2.7.0) + oxlint: 1.73.0(oxlint-tsgolint@0.24.0) + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + - typescript + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + eslint-visitor-keys@3.4.3: {} eslint-visitor-keys@4.2.1: {} eslint-visitor-keys@5.0.1: {} + eslint@10.5.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + eslint@9.39.5(jiti@2.7.0): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) @@ -5916,6 +6644,12 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 4.2.1 + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + esprima@4.0.1: {} esquery@1.7.0: @@ -5951,9 +6685,9 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - expect-type@1.4.0: {} + expect-type@1.3.0: {} - exsolve@1.1.0: {} + exsolve@1.0.8: {} fast-deep-equal@3.1.3: {} @@ -5967,7 +6701,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.3: {} + fast-uri@3.1.2: {} fast-wrap-ansi@0.2.2: dependencies: @@ -5993,7 +6727,7 @@ snapshots: dependencies: app-module-path: 2.2.0 commander: 12.1.0 - enhanced-resolve: 5.24.1 + enhanced-resolve: 5.24.0 module-definition: 6.0.2 module-lookup-amd: 9.1.3 resolve: 1.22.12 @@ -6202,10 +6936,10 @@ snapshots: oxc-parser: 0.137.0 oxc-resolver: 11.21.3 picomatch: 4.0.5 - smol-toml: 1.7.0 + smol-toml: 1.6.1 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 - unbash: 4.0.2 + unbash: 4.0.1 yaml: 2.9.0 zod: 4.4.3 @@ -6214,7 +6948,7 @@ snapshots: launch-editor@2.14.1: dependencies: picocolors: 1.1.1 - shell-quote: 1.9.0 + shell-quote: 1.8.4 lefthook-darwin-arm64@2.1.10: optional: true @@ -6371,11 +7105,11 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.6 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.16 minimist@1.2.8: {} @@ -6402,7 +7136,7 @@ snapshots: mute-stream@3.0.0: {} - nanoid@3.3.15: {} + nanoid@3.3.12: {} natural-compare@1.4.0: {} @@ -6598,7 +7332,7 @@ snapshots: dependencies: p-limit: 3.1.0 - package-manager-detector@1.7.0: {} + package-manager-detector@1.6.0: {} parent-module@1.0.1: dependencies: @@ -6639,7 +7373,7 @@ snapshots: pkg-types@2.3.1: dependencies: confbox: 0.2.4 - exsolve: 1.1.0 + exsolve: 1.0.8 pathe: 2.0.3 pluralize@8.0.0: {} @@ -6652,16 +7386,16 @@ snapshots: '@clack/prompts': 1.7.0 '@inquirer/prompts': 8.5.2(@types/node@24.13.3) - postcss-values-parser@6.0.2(postcss@8.5.16): + postcss-values-parser@6.0.2(postcss@8.5.15): dependencies: color-name: 1.1.4 is-url-superb: 4.0.0 - postcss: 8.5.16 + postcss: 8.5.15 quote-unquote: 1.0.0 - postcss@8.5.16: + postcss@8.5.15: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -6674,7 +7408,7 @@ snapshots: detective-amd: 6.1.0 detective-cjs: 6.1.1 detective-es6: 5.0.2 - detective-postcss: 8.0.4(postcss@8.5.16) + detective-postcss: 8.0.4(postcss@8.5.15) detective-sass: 6.0.2 detective-scss: 5.0.2 detective-stylus: 5.0.1 @@ -6682,7 +7416,7 @@ snapshots: detective-vue2: 2.3.0(typescript@5.9.3) module-definition: 6.0.2 node-source-walk: 7.0.2 - postcss: 8.5.16 + postcss: 8.5.15 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6699,8 +7433,8 @@ snapshots: publint@0.3.21: dependencies: - '@publint/pack': 0.1.5 - package-manager-detector: 1.7.0 + '@publint/pack': 0.1.4 + package-manager-detector: 1.6.0 picocolors: 1.1.1 sade: 1.8.1 @@ -6761,6 +7495,21 @@ 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) @@ -6768,14 +7517,35 @@ snapshots: obug: 2.1.3 rolldown: 1.1.5 yuku-ast: 0.1.7 - yuku-codegen: 0.6.1 - yuku-parser: 0.6.1 + yuku-codegen: 0.6.3 + yuku-parser: 0.6.3 optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260707.2 typescript: 6.0.3 transitivePeerDependencies: - oxc-resolver + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -6810,7 +7580,7 @@ snapshots: sass-lookup@6.1.2: dependencies: commander: 12.1.0 - enhanced-resolve: 5.24.1 + enhanced-resolve: 5.24.0 semver@7.8.5: {} @@ -6820,7 +7590,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.9.0: {} + shell-quote@1.8.4: {} siginfo@2.0.0: {} @@ -6836,7 +7606,7 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 - smol-toml@1.7.0: {} + smol-toml@1.6.1: {} sonda@0.14.0: dependencies: @@ -6931,6 +7701,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + ts-cron-validator@1.1.5: {} ts-graphviz@2.1.6: @@ -6951,6 +7725,33 @@ 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): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + 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) + semver: 7.8.5 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + optionalDependencies: + publint: 0.3.21 + tsx: 4.23.1 + typescript: 6.0.3 + transitivePeerDependencies: + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - vue-tsc + tsdown@0.22.7(@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): dependencies: ansis: 4.3.1 @@ -6999,7 +7800,7 @@ snapshots: typescript@6.0.3: {} - unbash@4.0.2: {} + unbash@4.0.1: {} unconfig-core@7.5.0: dependencies: @@ -7018,12 +7819,12 @@ snapshots: util-deprecate@1.0.2: {} - vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): + 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): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 - postcss: 8.5.16 - rolldown: 1.1.5 + postcss: 8.5.15 + rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 @@ -7033,17 +7834,17 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + vitest@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)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/mocker': 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)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.0 - expect-type: 1.4.0 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 @@ -7053,7 +7854,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + 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) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -7102,37 +7903,69 @@ snapshots: dependencies: '@yuku-toolchain/types': 0.5.43 - yuku-codegen@0.6.1: + 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 + optionalDependencies: + '@yuku-codegen/binding-darwin-arm64': 0.6.3 + '@yuku-codegen/binding-darwin-x64': 0.6.3 + '@yuku-codegen/binding-freebsd-x64': 0.6.3 + '@yuku-codegen/binding-linux-arm-gnu': 0.6.3 + '@yuku-codegen/binding-linux-arm-musl': 0.6.3 + '@yuku-codegen/binding-linux-arm64-gnu': 0.6.3 + '@yuku-codegen/binding-linux-arm64-musl': 0.6.3 + '@yuku-codegen/binding-linux-x64-gnu': 0.6.3 + '@yuku-codegen/binding-linux-x64-musl': 0.6.3 + '@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-codegen/binding-darwin-arm64': 0.6.1 - '@yuku-codegen/binding-darwin-x64': 0.6.1 - '@yuku-codegen/binding-freebsd-x64': 0.6.1 - '@yuku-codegen/binding-linux-arm-gnu': 0.6.1 - '@yuku-codegen/binding-linux-arm-musl': 0.6.1 - '@yuku-codegen/binding-linux-arm64-gnu': 0.6.1 - '@yuku-codegen/binding-linux-arm64-musl': 0.6.1 - '@yuku-codegen/binding-linux-x64-gnu': 0.6.1 - '@yuku-codegen/binding-linux-x64-musl': 0.6.1 - '@yuku-codegen/binding-win32-arm64': 0.6.1 - '@yuku-codegen/binding-win32-x64': 0.6.1 - - yuku-parser@0.6.1: + '@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 optionalDependencies: - '@yuku-parser/binding-darwin-arm64': 0.6.1 - '@yuku-parser/binding-darwin-x64': 0.6.1 - '@yuku-parser/binding-freebsd-x64': 0.6.1 - '@yuku-parser/binding-linux-arm-gnu': 0.6.1 - '@yuku-parser/binding-linux-arm-musl': 0.6.1 - '@yuku-parser/binding-linux-arm64-gnu': 0.6.1 - '@yuku-parser/binding-linux-arm64-musl': 0.6.1 - '@yuku-parser/binding-linux-x64-gnu': 0.6.1 - '@yuku-parser/binding-linux-x64-musl': 0.6.1 - '@yuku-parser/binding-win32-arm64': 0.6.1 - '@yuku-parser/binding-win32-x64': 0.6.1 + '@yuku-parser/binding-darwin-arm64': 0.6.3 + '@yuku-parser/binding-darwin-x64': 0.6.3 + '@yuku-parser/binding-freebsd-x64': 0.6.3 + '@yuku-parser/binding-linux-arm-gnu': 0.6.3 + '@yuku-parser/binding-linux-arm-musl': 0.6.3 + '@yuku-parser/binding-linux-arm64-gnu': 0.6.3 + '@yuku-parser/binding-linux-arm64-musl': 0.6.3 + '@yuku-parser/binding-linux-x64-gnu': 0.6.3 + '@yuku-parser/binding-linux-x64-musl': 0.6.3 + '@yuku-parser/binding-win32-arm64': 0.6.3 + '@yuku-parser/binding-win32-x64': 0.6.3 zinfer@0.2.5(typescript@6.0.3)(zod@4.4.3): dependencies: diff --git a/scripts/oxlint/tailor-zod-plugin.cjs b/scripts/oxlint/tailor-zod-plugin.cjs new file mode 100644 index 000000000..83e187f37 --- /dev/null +++ b/scripts/oxlint/tailor-zod-plugin.cjs @@ -0,0 +1,72 @@ +"use strict"; + +const OBJECT_POLICY_COMMENT_PATTERN = /\b(catchall|strip)\b/i; + +function getPropertyName(property) { + if (property == null) { + return undefined; + } + + if (property.type === "Identifier") { + return property.name; + } + + if (property.type === "Literal") { + return property.value; + } + + return undefined; +} + +function isZObjectCall(node) { + return ( + node.callee.type === "MemberExpression" && + node.callee.object.type === "Identifier" && + node.callee.object.name === "z" && + getPropertyName(node.callee.property) === "object" + ); +} + +function hasObjectPolicyComment(sourceCode, node) { + const previousLine = sourceCode.getText().split(/\r?\n/)[node.loc.start.line - 2] ?? ""; + const trimmedPreviousLine = previousLine.trimStart(); + return ( + (trimmedPreviousLine.startsWith("//") || trimmedPreviousLine.startsWith("/*")) && + OBJECT_POLICY_COMMENT_PATTERN.test(trimmedPreviousLine) + ); +} + +module.exports = { + meta: { + name: "tailor-zod", + }, + rules: { + "require-object-policy-comment": { + meta: { + type: "problem", + docs: { + description: "Require an unknown-key policy comment for z.object().", + }, + schema: [], + messages: { + missingObjectPolicyComment: + 'Add a previous-line comment containing "strip" or "catchall" for z.object(), or use z.strictObject() / z.looseObject().', + }, + }, + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + return { + CallExpression(node) { + if (isZObjectCall(node) && !hasObjectPolicyComment(sourceCode, node)) { + context.report({ + node, + messageId: "missingObjectPolicyComment", + }); + } + }, + }; + }, + }, + }, +}; diff --git a/skills/tailor-sdk/SKILL.md b/skills/tailor/SKILL.md similarity index 95% rename from skills/tailor-sdk/SKILL.md rename to skills/tailor/SKILL.md index 7d1dcdcf2..e1ca701de 100644 --- a/skills/tailor-sdk/SKILL.md +++ b/skills/tailor/SKILL.md @@ -1,6 +1,8 @@ --- -name: tailor-sdk +name: tailor description: Use this skill when working with @tailor-platform/sdk projects, including service configuration, CLI usage, and docs navigation. +metadata: + politty-cli: "@tailor-platform/sdk:tailor" --- # Tailor SDK Knowledge