@tailor-platform/sdk 2.26.0 → 2.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/agent-skills/tailor/SKILL.md +4 -2
  3. package/dist/application-CgJLyJaF.mjs +200 -0
  4. package/dist/application-CgJLyJaF.mjs.map +1 -0
  5. package/dist/application-CvmKh5WT.mjs +1 -0
  6. package/dist/cli/commands/deploy/deploy.d.mts +2 -0
  7. package/dist/cli/commands/deploy/tailordb/migration-validation.d.mts +2 -1
  8. package/dist/cli/commands/executor/get.d.mts +2 -1
  9. package/dist/cli/commands/executor/jobs.d.mts +2 -1
  10. package/dist/cli/commands/executor/trigger.d.mts +2 -1
  11. package/dist/cli/commands/function/get.d.mts +2 -1
  12. package/dist/cli/commands/function/list.d.mts +2 -1
  13. package/dist/cli/commands/organization/folder/create.d.mts +2 -1
  14. package/dist/cli/commands/organization/folder/delete.d.mts +2 -1
  15. package/dist/cli/commands/organization/folder/get.d.mts +2 -1
  16. package/dist/cli/commands/organization/folder/list.d.mts +2 -1
  17. package/dist/cli/commands/organization/folder/update.d.mts +2 -1
  18. package/dist/cli/commands/organization/get.d.mts +2 -1
  19. package/dist/cli/commands/organization/tree.d.mts +2 -1
  20. package/dist/cli/commands/organization/update.d.mts +2 -1
  21. package/dist/cli/commands/show.d.mts +2 -1
  22. package/dist/cli/commands/tailordb/migrate/bundler.d.mts +4 -6
  23. package/dist/cli/commands/tailordb/migrate/diff-calculator.d.mts +8 -1
  24. package/dist/cli/commands/tailordb/migrate/remote-state.d.mts +1 -0
  25. package/dist/cli/commands/tailordb/migrate/snapshot-types.d.mts +3 -1
  26. package/dist/cli/commands/workflow/executions.d.mts +2 -1
  27. package/dist/cli/commands/workflow/get.d.mts +2 -1
  28. package/dist/cli/commands/workflow/start.d.mts +2 -1
  29. package/dist/cli/commands/workspace/app/health.d.mts +2 -1
  30. package/dist/cli/commands/workspace/app/list.d.mts +2 -1
  31. package/dist/cli/commands/workspace/create.d.mts +5 -2
  32. package/dist/cli/commands/workspace/delete.d.mts +2 -1
  33. package/dist/cli/commands/workspace/get.d.mts +2 -1
  34. package/dist/cli/commands/workspace/restore.d.mts +2 -1
  35. package/dist/cli/commands/workspace/user/invite.d.mts +2 -1
  36. package/dist/cli/commands/workspace/user/list.d.mts +2 -1
  37. package/dist/cli/commands/workspace/user/remove.d.mts +2 -1
  38. package/dist/cli/commands/workspace/user/update.d.mts +2 -1
  39. package/dist/cli/lib.d.mts +3 -3
  40. package/dist/cli/lib.mjs +1 -1
  41. package/dist/cli/lib.mjs.map +1 -1
  42. package/dist/cli/main.d.mts +2 -0
  43. package/dist/cli/main.mjs +68 -55
  44. package/dist/cli/main.mjs.map +1 -1
  45. package/dist/cli/query/index.d.mts +2 -1
  46. package/dist/cli/shared/args.d.mts +4 -2
  47. package/dist/cli/shared/client.d.mts +6 -1
  48. package/dist/cli/shared/context.d.mts +2 -1
  49. package/dist/cli/shared/logger.d.mts +5 -2
  50. package/dist/cli/shared/readonly-guard.d.mts +2 -1
  51. package/dist/cli/shared/seed-context.d.mts +5 -0
  52. package/dist/cli/shared/type-generator.d.mts +2 -1
  53. package/dist/cli/shared/workspace-name.d.mts +2 -0
  54. package/dist/completion/zsh-worker.zsh +220 -19
  55. package/dist/configure/config/types.d.mts +34 -1
  56. package/dist/configure/index.d.mts +6 -6
  57. package/dist/configure/index.mjs +1 -1
  58. package/dist/configure/index.mjs.map +1 -1
  59. package/dist/configure/services/aigateway/types.d.mts +2 -1
  60. package/dist/configure/services/auth/types.d.mts +8 -2
  61. package/dist/configure/services/executor/executor.d.mts +2 -1
  62. package/dist/configure/services/executor/operation.d.mts +2 -1
  63. package/dist/configure/services/executor/trigger/event.d.mts +3 -2
  64. package/dist/configure/services/executor/trigger/schedule.d.mts +2 -1
  65. package/dist/configure/services/executor/trigger/webhook.d.mts +1 -1
  66. package/dist/configure/services/http-adapter/http-adapter.d.mts +2 -1
  67. package/dist/configure/services/idp/permission.d.mts +4 -3
  68. package/dist/configure/services/idp/types.d.mts +2 -1
  69. package/dist/configure/services/resolver/permission.d.mts +2 -1
  70. package/dist/configure/services/resolver/resolver.d.mts +3 -2
  71. package/dist/configure/services/secrets/types.d.mts +2 -1
  72. package/dist/configure/services/staticwebsite/types.d.mts +2 -1
  73. package/dist/configure/services/tailordb/permission.d.mts +2 -1
  74. package/dist/configure/services/tailordb/schema.d.mts +5 -4
  75. package/dist/configure/services/tailordb/types.d.mts +13 -2
  76. package/dist/configure/services/workflow/execution-policy.types.d.mts +2 -1
  77. package/dist/configure/services/workflow/job.d.mts +3 -2
  78. package/dist/configure/services/workflow/wait-point.d.mts +2 -1
  79. package/dist/configure/types/field.types.d.mts +50 -12
  80. package/dist/configure/types/permission-operand.types.d.mts +2 -1
  81. package/dist/configure/types/type.d.mts +3 -2
  82. package/dist/{crashreport-DFpRn-LS.mjs → crashreport-BAG8c7hB.mjs} +4 -4
  83. package/dist/{crashreport-DFpRn-LS.mjs.map → crashreport-BAG8c7hB.mjs.map} +1 -1
  84. package/dist/crashreport-BcXuocwI.mjs +1 -0
  85. package/dist/date-CgP7gMG_.mjs +2 -0
  86. package/dist/date-CgP7gMG_.mjs.map +1 -0
  87. package/dist/{secretmanager-5olfnI1b.mjs → date-representation-Cr7W7Pn2.mjs} +2 -2
  88. package/dist/date-representation-Cr7W7Pn2.mjs.map +1 -0
  89. package/dist/{errors-CpvSChyL.mjs → errors-D3qpNHR6.mjs} +2 -2
  90. package/dist/{errors-CpvSChyL.mjs.map → errors-D3qpNHR6.mjs.map} +1 -1
  91. package/dist/{field-parse-DhnFs9WG.mjs → field-parse-C41e3UAF.mjs} +2 -2
  92. package/dist/{field-parse-DhnFs9WG.mjs.map → field-parse-C41e3UAF.mjs.map} +1 -1
  93. package/dist/idp-G_ojPBB5.mjs.map +1 -1
  94. package/dist/kysely-type-BWMfV301.mjs +48 -0
  95. package/dist/kysely-type-BWMfV301.mjs.map +1 -0
  96. package/dist/logger-DuaT2zY3.mjs +9 -0
  97. package/dist/logger-DuaT2zY3.mjs.map +1 -0
  98. package/dist/{manager-8-JOvuLl.mjs → manager-Bk-7vkwd.mjs} +2 -2
  99. package/dist/{manager-8-JOvuLl.mjs.map → manager-Bk-7vkwd.mjs.map} +1 -1
  100. package/dist/migration-steps-C6F3zNdQ.mjs +3 -0
  101. package/dist/migration-steps-C6F3zNdQ.mjs.map +1 -0
  102. package/dist/package-json-D1BRkjtr.mjs +1 -0
  103. package/dist/parser/service/tailordb/types.d.mts +3 -1
  104. package/dist/plugin/builtin/enum-constants/index.d.mts +2 -1
  105. package/dist/plugin/builtin/file-utils/index.d.mts +2 -1
  106. package/dist/plugin/builtin/kysely-type/index.d.mts +2 -1
  107. package/dist/plugin/builtin/kysely-type/index.mjs +1 -1
  108. package/dist/plugin/builtin/seed/index.d.mts +11 -1
  109. package/dist/plugin/builtin/seed/index.mjs +1 -1
  110. package/dist/plugin/index.mjs +1 -1
  111. package/dist/register-ts-hook-yxshv0LV.mjs +1013 -0
  112. package/dist/register-ts-hook-yxshv0LV.mjs.map +1 -0
  113. package/dist/runtime/aigateway.mjs +1 -1
  114. package/dist/runtime/authconnection.mjs +1 -1
  115. package/dist/runtime/context.d.mts +2 -1
  116. package/dist/runtime/context.mjs +1 -1
  117. package/dist/runtime/date.d.mts +2 -1
  118. package/dist/runtime/field-parse.d.mts +2 -1
  119. package/dist/runtime/file.d.mts +2 -1
  120. package/dist/runtime/file.mjs +1 -1
  121. package/dist/runtime/globals.d.mts +2 -1
  122. package/dist/runtime/iconv.mjs +1 -1
  123. package/dist/runtime/idp.d.mts +6 -0
  124. package/dist/runtime/idp.mjs +1 -1
  125. package/dist/runtime/index.mjs +1 -1
  126. package/dist/runtime/logger.mjs +1 -1
  127. package/dist/runtime/secretmanager.mjs +1 -1
  128. package/dist/runtime/types.d.mts +7 -1
  129. package/dist/runtime/workflow.d.mts +2 -1
  130. package/dist/runtime/workflow.mjs +1 -1
  131. package/dist/{schema-CRtAmDcl.mjs → schema-B6hJQWp6.mjs} +2 -2
  132. package/dist/schema-B6hJQWp6.mjs.map +1 -0
  133. package/dist/seed/index.d.mts +19 -4
  134. package/dist/seed/index.mjs +7 -9
  135. package/dist/seed/index.mjs.map +1 -1
  136. package/dist/seed-C3P_T_Eh.mjs.map +1 -1
  137. package/dist/service-CETr0AKY.mjs +7 -0
  138. package/dist/service-CETr0AKY.mjs.map +1 -0
  139. package/dist/service-DREWLYxD.mjs +1 -0
  140. package/dist/{service-DFmt9UJP.mjs → service-b1g22NN9.mjs} +2 -2
  141. package/dist/{service-DFmt9UJP.mjs.map → service-b1g22NN9.mjs.map} +1 -1
  142. package/dist/service_pb-Bbu2QSIr.mjs +2 -0
  143. package/dist/service_pb-Bbu2QSIr.mjs.map +1 -0
  144. package/dist/service_pb-urxAIYHT.mjs +1 -0
  145. package/dist/tailor-proto/src/tailor/v1/events_pb.d.mts +53 -1
  146. package/dist/tailor-proto/src/tailor/v1/events_resource_pb.d.mts +33 -0
  147. package/dist/tailor-proto/src/tailor/v1/function_resource_pb.d.mts +0 -8
  148. package/dist/tailor-proto/src/tailor/v1/service_pb.d.mts +18 -1
  149. package/dist/tailor-proto/src/tailor/v1/tailordb_resource_pb.d.mts +4 -0
  150. package/dist/telemetry-DpkFGBmu.mjs +1 -0
  151. package/dist/{telemetry-Bklv9kQY.mjs → telemetry-Xk7J829P.mjs} +2 -2
  152. package/dist/{telemetry-Bklv9kQY.mjs.map → telemetry-Xk7J829P.mjs.map} +1 -1
  153. package/dist/types/field.generated.d.mts +2 -2
  154. package/dist/types/tailordb.generated.d.mts +2 -0
  155. package/dist/utils/test/index.mjs +1 -1
  156. package/dist/vitest/date-representation.d.mts +23 -0
  157. package/dist/vitest/index.d.mts +3 -1
  158. package/dist/vitest/index.mjs +1 -1
  159. package/dist/vitest/index.mjs.map +1 -1
  160. package/dist/vitest/migration-steps.d.mts +45 -0
  161. package/dist/vitest/mock-kysely.d.mts +2 -1
  162. package/dist/vitest/mocks/aigateway.d.mts +2 -1
  163. package/dist/vitest/mocks/authconnection.d.mts +2 -1
  164. package/dist/vitest/mocks/file.d.mts +2 -1
  165. package/dist/vitest/mocks/iconv.d.mts +2 -1
  166. package/dist/vitest/mocks/idp.d.mts +3 -1
  167. package/dist/vitest/mocks/logger.d.mts +2 -1
  168. package/dist/vitest/mocks/secretmanager.d.mts +2 -1
  169. package/dist/vitest/mocks/tailordb-pglite.d.mts +2 -1
  170. package/dist/vitest/mocks/tailordb.d.mts +2 -1
  171. package/dist/vitest/mocks/workflow.d.mts +3 -1
  172. package/dist/vitest/pglite-kysely.d.mts +2 -1
  173. package/dist/vitest/setup.d.mts +3 -1
  174. package/dist/vitest/setup.mjs +1 -1
  175. package/dist/vitest/setup.mjs.map +1 -1
  176. package/dist/vitest/workflow-local.d.mts +2 -1
  177. package/dist/{workspace_resource_pb-CxR_6fyN.mjs → workspace_resource_pb-CaoM2npP.mjs} +2 -2
  178. package/dist/workspace_resource_pb-CaoM2npP.mjs.map +1 -0
  179. package/docs/cli/application.md +37 -16
  180. package/docs/cli/data.md +105 -0
  181. package/docs/cli/function.md +2 -0
  182. package/docs/cli/secret.md +6 -6
  183. package/docs/cli/tailordb.md +18 -16
  184. package/docs/cli/user.md +37 -7
  185. package/docs/cli/workspace.md +10 -2
  186. package/docs/cli-reference.md +25 -3
  187. package/docs/configuration.md +28 -6
  188. package/docs/github-actions.md +212 -63
  189. package/docs/multi-environment.md +24 -0
  190. package/docs/plugin/custom.md +2 -1
  191. package/docs/services/auth.md +20 -0
  192. package/docs/services/executor.md +2 -2
  193. package/docs/services/idp.md +6 -1
  194. package/docs/services/resolver.md +3 -1
  195. package/docs/services/staticwebsite.md +5 -5
  196. package/docs/services/tailordb-migration.md +222 -41
  197. package/docs/services/tailordb.md +17 -4
  198. package/docs/testing.md +4 -0
  199. package/package.json +18 -18
  200. package/dist/application-ChqHuhZW.mjs +0 -1
  201. package/dist/application-DS0XKBtK.mjs +0 -200
  202. package/dist/application-DS0XKBtK.mjs.map +0 -1
  203. package/dist/crashreport-CBaRJl5h.mjs +0 -1
  204. package/dist/date-Dri6Yas-.mjs +0 -2
  205. package/dist/date-Dri6Yas-.mjs.map +0 -1
  206. package/dist/kysely-type-RDX59E1j.mjs +0 -48
  207. package/dist/kysely-type-RDX59E1j.mjs.map +0 -1
  208. package/dist/logger-Db-YRTwK.mjs +0 -9
  209. package/dist/logger-Db-YRTwK.mjs.map +0 -1
  210. package/dist/package-json-CmqhDJ__.mjs +0 -1
  211. package/dist/register-ts-hook-BuwkLuxC.mjs +0 -925
  212. package/dist/register-ts-hook-BuwkLuxC.mjs.map +0 -1
  213. package/dist/schema-CRtAmDcl.mjs.map +0 -1
  214. package/dist/secretmanager-5olfnI1b.mjs.map +0 -1
  215. package/dist/service-Bx-7Lz06.mjs +0 -1
  216. package/dist/service-CL1Bp4It.mjs +0 -7
  217. package/dist/service-CL1Bp4It.mjs.map +0 -1
  218. package/dist/service_pb-ZClWtj1X.mjs +0 -2
  219. package/dist/service_pb-ZClWtj1X.mjs.map +0 -1
  220. package/dist/service_pb-dB3RrGeh.mjs +0 -1
  221. package/dist/telemetry-DXHP3VVi.mjs +0 -1
  222. package/dist/workspace_resource_pb-CxR_6fyN.mjs.map +0 -1
@@ -97,15 +97,19 @@ A typical change cycle:
97
97
  3. **Edit `migrate.ts`** to populate data for the new required field:
98
98
 
99
99
  ```typescript
100
- import type { Transaction } from "./db";
100
+ import type { MigrationSteps, Transaction } from "./db";
101
101
 
102
- export async function main(trx: Transaction): Promise<void> {
102
+ async function populateUserEmail(trx: Transaction): Promise<void> {
103
103
  await trx
104
104
  .updateTable("User")
105
105
  .set({ email: "default@example.com" })
106
106
  .where("email", "is", null)
107
107
  .execute();
108
108
  }
109
+
110
+ export const steps = {
111
+ populateUserEmail: { run: populateUserEmail },
112
+ } satisfies MigrationSteps;
109
113
  ```
110
114
 
111
115
  4. **Apply.**
@@ -236,7 +240,7 @@ Sometimes existing data must be transformed without any schema change — fixing
236
240
  tailor tailordb migration generate --data-only --name "normalize legacy phone numbers"
237
241
  ```
238
242
 
239
- This writes a numbered migration with an empty `diff.json`, a `migrate.ts` skeleton, and `db.ts` typed against the current schema. Edit `migrate.ts` to implement the transformation; the next `tailor deploy` runs it like any other migration script — in a single transaction, advancing the migration checkpoint (see [Performance and Large Tables](#performance-and-large-tables) for batching patterns). Because the entry is part of the migration history, the fix is versioned, ordered relative to schema changes, and applied once per workspace.
243
+ This writes a numbered migration with an empty `diff.json`, a `migrate.ts` skeleton (a single `migrate` step), and `db.ts` typed against the current schema. Edit `migrate.ts` to implement the transformation; the next `tailor deploy` runs it like any other migration script — in a single transaction, advancing the migration checkpoint (see [Performance and Large Tables](#performance-and-large-tables) for batching patterns). Because the entry is part of the migration history, the fix is versioned, ordered relative to schema changes, and applied once per workspace.
240
244
 
241
245
  The command requires a clean state: if the namespace has schema changes that are not yet in migration files, generate the schema migration first. With multiple namespaces, pass `--namespace` to name the target. `--data-only` cannot be combined with `--init`, `--rename`, `--drop`, or `--expand-contract`.
242
246
 
@@ -268,21 +272,21 @@ export default defineConfig({
268
272
 
269
273
  ## Generated Files
270
274
 
271
- | File | When generated | Description |
272
- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
273
- | `0000/schema.json` | First `migration generate` | Full snapshot of all tables in the namespace. |
274
- | `XXXX/diff.json` | Every subsequent migration | Field-level diff against the previous snapshot. |
275
- | `XXXX/migrate.ts` | Auto-generated for breaking changes and `--data-only` migrations; added manually via `tailordb migration script` for warning-tier changes | Data transformation script. The `main` export receives a Kysely `Transaction`. |
276
- | `XXXX/db.ts` | Generated once when `migrate.ts` is created | Kysely types reflecting the schema **before** this migration. Exports `Database`, `Transaction`, and `MigrationContext`. |
277
- | `XXXX/db.pglite.ts` | Generated with `db.ts` | `CREATE TABLE` script of the same schema, for running `migrate.ts` on PGlite. Never deployed. |
278
- | `XXXX/migrate.test.ts` | Added via `tailordb migration script --with-test` | Unit-test scaffold for `migrate.ts` (see [Testing Migrations Locally](#testing-migrations-locally)). Never deployed. |
279
- | `XXXX/migrate.pglite.test.ts` | Added via `tailordb migration script --with-test` when `@electric-sql/pglite` is installed | PGlite test scaffold for `migrate.ts`. Never deployed. |
275
+ | File | When generated | Description |
276
+ | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
277
+ | `0000/schema.json` | First `migration generate` | Full snapshot of all tables in the namespace. |
278
+ | `XXXX/diff.json` | Every subsequent migration | Field-level diff against the previous snapshot. |
279
+ | `XXXX/migrate.ts` | Auto-generated for breaking changes and `--data-only` migrations; added manually via `tailordb migration script` for warning-tier changes | Data transformation script. The `main` export receives a Kysely `Transaction`; a `steps` export runs each step in its own transaction. |
280
+ | `XXXX/db.ts` | Generated once when `migrate.ts` is created | Kysely types reflecting the schema **before** this migration. Exports `Database`, `Transaction`, and `MigrationContext`. |
281
+ | `XXXX/db.pglite.ts` | Generated with `db.ts` | `CREATE TABLE` script of the same schema, for running `migrate.ts` on PGlite. Never deployed. |
282
+ | `XXXX/migrate.test.ts` | Added via `tailordb migration script --with-test` | Unit-test scaffold for `migrate.ts` (see [Testing Migrations Locally](#testing-migrations-locally)). Never deployed. |
283
+ | `XXXX/migrate.pglite.test.ts` | Added via `tailordb migration script --with-test` when `@electric-sql/pglite` is installed | PGlite test scaffold for `migrate.ts`. Never deployed. |
280
284
 
281
285
  `db.ts` reflects the pre-migration schema because the script runs after the pre-migration phase has temporarily relaxed breaking constraints (e.g., a new `required` field is added as `optional` first), so the data being read still matches the previous shape.
282
286
 
283
287
  ### Migration file format compatibility
284
288
 
285
- Migration files are versioned independently of the SDK package. This SDK writes format version `7` and reads versions `1` through `7`. It normalizes supported older formats in memory; it never rewrites applied migration files on disk. Format version `6` records renames of members inside nested fields (`memberRenames`); older SDK versions refuse to read it rather than deploying such a migration without the copy step. Format version `7` records whether a migration's `db.ts` uses Temporal column types (`temporal`); older SDK versions refuse to read it rather than running the script with `Date` values.
289
+ Migration files are versioned independently of the SDK package. This SDK writes format version `8` and reads versions `1` through `8`. It normalizes supported older formats in memory; it never rewrites applied migration files on disk. Format version `6` records renames of members inside nested fields (`memberRenames`); older SDK versions refuse to read it rather than deploying such a migration without the copy step. Format version `7` records whether a migration's `db.ts` uses Temporal column types (`temporal`); older SDK versions refuse to read it rather than running the script with `Date` values. Format version `8` records the `defaultDateRepresentation` a migration script was generated under (`dateRepresentation`); older SDK versions refuse to read it rather than running the script with string values.
286
290
 
287
291
  Supported histories also preserve the behavior of field hooks and validators saved by older SDKs, including access to the record and boolean validators with a separate error message. Legacy update hooks retain existing values for omitted fields; explicitly supplied values, including `null`, take precedence. This applies to both snapshots and diffs, including nested fields. Your existing migration files can remain as generated.
288
292
 
@@ -294,6 +298,8 @@ There is no migration-file conversion command. Keeping applied files unchanged p
294
298
 
295
299
  ## Migration Script Anatomy
296
300
 
301
+ `migration generate` and `migration script` scaffold `migrate.ts` as `steps`, with one step for each schema change that needs a data migration (see [Splitting a migration into steps](#splitting-a-migration-into-steps)). The examples in this section use `main`, the single-transaction form, which still works. A `steps` script receives the same transaction and `MigrationContext` in each step's `run`.
302
+
297
303
  ```typescript
298
304
  import type { Transaction } from "./db";
299
305
 
@@ -365,6 +371,85 @@ The `env` values are injected at bundle time (the same mechanism as resolvers/ex
365
371
  - `console.log` / `console.error` output is captured and surfaced under `Logs:` in the apply output. Use it sparingly for progress markers on long-running migrations.
366
372
  - The script is idempotency-friendly by default because it runs inside a transaction, but **plan for re-execution**: if a later migration in the same `apply` fails, the platform may retry the apply, and you may want your script to tolerate already-migrated rows (e.g., `where("email", "is", null)` instead of unconditional updates).
367
373
 
374
+ ### Splitting a migration into steps
375
+
376
+ A script that exports `main` runs as one job in one transaction. When the work is too large for one job, or splits naturally into independent parts, export `steps` instead. Each step runs as its own job in its own transaction, so each step gets the job's execution-time limit to itself and commits on its own.
377
+
378
+ ```typescript
379
+ import type { MigrationSteps } from "./db";
380
+
381
+ export const steps = {
382
+ backfillCustomer: {
383
+ run: async (trx) => {
384
+ await trx
385
+ .updateTable("Customer")
386
+ .set((eb) => ({ updatedAt: eb.ref("createdAt") }))
387
+ .where("updatedAt", "is", null)
388
+ .execute();
389
+ },
390
+ },
391
+ backfillInvoice: {
392
+ run: async (trx) => {
393
+ await trx
394
+ .updateTable("Invoice")
395
+ .set((eb) => ({ updatedAt: eb.ref("createdAt") }))
396
+ .where("updatedAt", "is", null)
397
+ .execute();
398
+ },
399
+ },
400
+ recomputeTotals: {
401
+ dependsOn: ["backfillInvoice"],
402
+ run: async (trx, { env }) => {
403
+ // Runs only after backfillInvoice has completed.
404
+ },
405
+ },
406
+ } satisfies MigrationSteps;
407
+ ```
408
+
409
+ - A script exports either `main` or `steps`. If it exports both, `main` runs as before and `steps` is ignored with a warning from `tailor deploy` and `tailor tailordb migration validate`.
410
+ - Generated scripts export `steps`. Each schema change that needs a data migration gets its own step, named for what it does: `populate<Table><Field>` for an added required field (for example `populateUserEmail`), `rename<Table><Field>` for a renamed field, `copy<Old>To<New>` for a renamed table, `update<Table><Field>` for a changed field, `resolve<Table><Index>` for a unique index, and `convert<Table><Field>` for a field type conversion. When that name is not a valid step name (for example because it is longer than 64 characters), or when another step or a helper in the script already uses it, the step is named `change<N>` instead (`expand<N>` for a conversion through a temporary field), where N is the change's position in the migration; the fallback does not mean the schema-derived name was wrong. A step lists in `dependsOn` every earlier step that touches the same field (a foreign key that changes its target also touches `id` of the table it now points to), so steps that touch the same field run in the order the changes are listed and changes to different fields do not wait for each other. The copy of a renamed table's rows runs after the copies of the renamed tables its foreign keys point to, so the rows they reference already exist. It skips the rows whose ids the new table already has, so running it again does not insert them twice. A step that rewrites values that already exist (the duplicate resolution of a unique index or field, a field change, a conversion through a temporary field) has a comment above its function saying so: once it commits it cannot be undone, so check the values it writes before you deploy. Where the scaffold cannot pick a value for you (what an added required field holds in existing records, the new value of duplicates, the value that replaces a removed enum value, the new reference of a retargeted foreign key, the copy of renamed tables that reference each other), it calls `TODO(message)`, which `./db` exports. The migration fails at that call, and `tailordb migration validate` and `tailor deploy` reject a `migrate.ts` that still calls it before anything is changed. Replace each call with the value, then remove the `TODO` import. A `--data-only` migration, and a migration with no data statements to scaffold, get a single `migrate` step; split it into several steps where the work divides.
411
+ - Each step is an object with a `run` function — it receives its own transaction and the same `MigrationContext` as `main` — and an optional `dependsOn` list naming the steps that must complete before it starts.
412
+ - Steps with no dependency between them have no guaranteed order. Declaration order is not execution order, and a future SDK version may run independent steps concurrently, so declare `dependsOn` for every ordering your steps rely on.
413
+ - Declare `steps` directly as `export const steps = { ... }`, with each step written inside it as an object literal with a literal name and `dependsOn` list. Step names start with a letter and contain only letters, digits, and underscores, up to 64 characters. `tailor tailordb migration validate` and `tailor deploy` reject unknown dependencies and cycles before anything is changed.
414
+ - Each step runs as a separate job, so steps cannot hand data to each other through module-level variables; read what a step needs from the database.
415
+ - `MigrationSteps` is declared by `db.ts` files generated by this SDK version. With an older `db.ts`, annotate each `run` parameter with `Transaction` instead.
416
+
417
+ A script with a single step runs like a `main` script: one job and one transaction, and a failure inside the transaction rolls its data changes back. Nothing is recorded as in progress. If the post-migration schema changes fail after the transaction committed, the schema is restored to its state before the migration but the data changes stay, so the next deploy runs the step again and the step must tolerate rows it already migrated. The rules below apply once the script has two or more steps. A migration that an earlier deploy already left in progress keeps running step by step until it completes, even if the script has since been reduced to one step.
418
+
419
+ Because steps commit separately, a failure does not undo the steps that already completed:
420
+
421
+ - If the first step fails before any other step has completed, the migration fails like a `main` script: the pre-migration schema changes are rolled back.
422
+ - If a step fails after another step has completed, `tailor deploy` stops and leaves the migration **in progress**. The pre-migration schema stays in place and the checkpoint is not advanced. With [maintenance mode](#maintenance-mode) on, the namespace's tables stay in it, as while the migration runs: GraphQL create, update, and delete operations, bulk upsert, and record events remain disabled, while `read` keeps its setting. `tailor tailordb migration status` reports the migration as in progress.
423
+ - If `tailor deploy` cannot confirm whether the steps started, for example because the connection dropped while starting them, the migration also stays in progress. Run `tailor deploy` again; it checks whether the steps ran before continuing.
424
+ - Fix the failing step and run `tailor deploy` again. The deploy resumes the migration: completed steps do not run again, and the failed step and the steps after it run with the updated script. A completed step is treated like an applied migration — editing it afterwards does not run it again.
425
+ - If the steps changed since the earlier run, or that run is no longer available, every step runs again from the beginning. See [Editing steps of a migration in progress](#editing-steps-of-a-migration-in-progress).
426
+ - If every step completed but the post-migration schema changes fail (for example, a field the migration makes required still has empty values), the migration also stays in progress with its pre-migration schema, and the next deploy retries those schema changes without running the steps again.
427
+ - To run a completed step again (because it had a bug, or to fix rows the post-migration schema changes reject), change the steps: rename that step and update the `dependsOn` lists that name it, or add a step that fixes the rows. Every step then runs again from the beginning, so this relies on every step being safe to run again; to keep the other completed steps from running again, skip them as described below.
428
+ - While a migration is in progress, `migration set`, `migration sync`, and `migration rebaseline` refuse to run, and `migration test` refuses to use that workspace as its source. Finish the migration with `tailor deploy` first. The one exception is `migration set <N>` with the in-progress migration's own number: it marks that migration as completed and clears its in-progress record. Use it only when the migration's schema changes are already applied, for example after the deploy reported that the checkpoint write could not be confirmed.
429
+
430
+ #### Editing steps of a migration in progress
431
+
432
+ When `tailor deploy` finds a migration in progress, what you changed in `migrate.ts` decides how it continues:
433
+
434
+ | What you changed since the earlier deploy | What the next `tailor deploy` does |
435
+ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
436
+ | The code of a step that has not completed (the failed step or a later one) | Resumes: completed steps do not run again, and the rest run with the updated code. |
437
+ | The code of a step that already completed | Resumes, but the change takes no effect: that step does not run again. |
438
+ | A step added, removed, or renamed, or the order the steps run in | Runs every step again from the beginning, and warns about it first. The warning lists the steps that had already succeeded. |
439
+ | Nothing, but the earlier run was canceled or is no longer available | Runs every step again from the beginning, and warns about it first. |
440
+
441
+ Running every step again runs completed steps a second time, which breaks a step that is not safe to run twice. To keep specific steps from running again, pass them to `--migration-skip-steps` as comma-separated `<namespace>/<step>` entries:
442
+
443
+ ```bash
444
+ tailor deploy --migration-skip-steps main-db/backfillUser,main-db/backfillInvoice
445
+ ```
446
+
447
+ - A skipped step is reported as completed and is not run. Only steps that succeeded in the migration's earlier run can be skipped, so a step you renamed cannot be.
448
+ - The option applies to the migration an earlier deploy left in progress. Deploy fails before changing anything when an entry names a step that did not succeed, a step the migration does not define, a namespace without a migration in progress, or a namespace that no deployed config defines. It also fails when the earlier run's step results cannot be read, because then it cannot confirm which steps succeeded; deploy again to retry.
449
+ - It takes effect for the deploy you pass it to. If that run fails, the next `tailor deploy` resumes it as usual, and the skipped steps stay completed without passing the option again.
450
+
451
+ Write every step so that running it again is safe: besides the cases above, a step can run a second time when a deploy is interrupted right after it commits. Use `where` clauses that skip rows a step already migrated, as in the example. Rows written while a migration is in progress are not seen by steps that already completed. [Maintenance mode](#maintenance-mode) blocks GraphQL writes but not the writes your resolvers, executors, or workflows make through Kysely, and without it GraphQL clients can write too, so stop such writers until the migration finishes or write steps that tolerate them.
452
+
368
453
  ## Supported Schema Changes
369
454
 
370
455
  | Change Type | Breaking? | Migration Script? | Notes |
@@ -425,9 +510,9 @@ A `float` field that is already unique cannot convert to `decimal` in place, bec
425
510
  For example, changing `User.age` from `integer` to `float` generates a `migrate.ts` that scans non-null values in batches of 100:
426
511
 
427
512
  ```typescript
428
- import type { Transaction } from "./db";
513
+ import { TODO, type MigrationSteps, type Transaction } from "./db";
429
514
 
430
- export async function main(trx: Transaction): Promise<void> {
515
+ async function updateUserAge(trx: Transaction): Promise<void> {
431
516
  // Normalize User.age from integer to float while the previous type is still active
432
517
  {
433
518
  let lastId: string | undefined;
@@ -445,11 +530,11 @@ export async function main(trx: Transaction): Promise<void> {
445
530
  if (rows.length === 0) break;
446
531
 
447
532
  for (const row of rows) {
448
- // TODO(tailor-migration-review): Remove this marker and the `never` annotation after reviewing the normalization.
449
- // Keep the value accepted by the active integer type and castable to float.
450
533
  const sourceValue = row.age;
451
534
  if (sourceValue === null) continue;
452
- const normalizedValue: never = sourceValue;
535
+ const normalizedValue = TODO(
536
+ "normalize User.age to a value the active integer type accepts and the float type can cast",
537
+ );
453
538
  if (Object.is(normalizedValue, sourceValue)) continue;
454
539
  await trx
455
540
  .updateTable("User")
@@ -461,9 +546,13 @@ export async function main(trx: Transaction): Promise<void> {
461
546
  }
462
547
  }
463
548
  }
549
+
550
+ export const steps = {
551
+ updateUserAge: { run: updateUserAge },
552
+ } satisfies MigrationSteps;
464
553
  ```
465
554
 
466
- The generated `never` annotation intentionally causes a TypeScript error until you review the normalization. If the existing values are already suitable for the target type, remove the annotation and review marker to accept the identity transformation; it does not write any rows. If values need application-specific normalization, replace the expression and remove the annotation and marker while keeping the result valid for both the active source type and the target type. The source field contract remains active until the script finishes; for example, an `integer` → `float` script cannot write fractional values during this phase.
555
+ The generated `TODO()` call stops the migration until you replace it, and `tailordb migration validate` and `tailor deploy` reject the migration while it is still there. If the existing values are already suitable for the target type, replace the call with `sourceValue` to accept the identity transformation; it does not write any rows. If values need application-specific normalization, replace it with your expression, keeping the result valid for both the active source type and the target type. The source field contract remains active until the script finishes; for example, an `integer` → `float` script cannot write fractional values during this phase.
467
556
 
468
557
  ### Converting a field type
469
558
 
@@ -476,10 +565,10 @@ User.price changes from string to integer, which cannot be applied in one step.
476
565
 
477
566
  Confirming writes two migrations:
478
567
 
479
- 1. **The conversion.** Adds a temporary field (`priceMigrate`), converts each stored value into it, and clears and removes the original field. Edit the conversion expression before deploying: the generated `never` annotation fails your typecheck, and `tailordb migration validate` rejects the migration while the review marker is still there. When only the array-ness changes (`string` → `string[]`), the conversion stores each value as a one-element array and carries no review marker; when the element type changes as well (`integer` → `string[]`), you convert the element and the script wraps it.
568
+ 1. **The conversion.** Adds a temporary field (`priceMigrate`), converts each stored value into it, and clears and removes the original field. Edit the conversion expression before deploying: the generated `TODO()` call fails the migration, and `tailordb migration validate` and `tailor deploy` reject it while the call is still there. When only the array-ness changes (`string` → `string[]`), the conversion stores each value as a one-element array and has no `TODO()` call; when the element type changes as well (`integer` → `string[]`), you convert the element and the script wraps it.
480
569
  2. **The rename.** Renames the temporary field back to `price`. Its copy script is complete, but this migration also carries every other schema change the same run picked up, so review it as you would any generated migration.
481
570
 
482
- If converting to an array also reduces a decimal field's `scale` or removes enum values, the conversion keeps the review marker. Edit the element conversion to satisfy the target field before deploying.
571
+ If converting to an array also reduces a decimal field's `scale` or removes enum values, the conversion keeps the `TODO()` call. Edit the element conversion to satisfy the target field before deploying.
483
572
 
484
573
  `tailor deploy` applies both. Because the conversion only touches rows whose original value is still set, a re-run resumes where it stopped rather than converting a row twice.
485
574
 
@@ -508,7 +597,7 @@ Some changes are still rejected and need a temporary field you add yourself —
508
597
 
509
598
  The command performs the following sequence:
510
599
 
511
- 1. Reads each migration-enabled namespace's `sdk-migration` checkpoint from the source workspace and reconstructs that exact snapshot from local migration history.
600
+ 1. Reads each migration-enabled namespace's `sdk-migration` checkpoint from the source workspace and reconstructs that exact snapshot from local migration history. A deployed namespace that has no checkpoint is treated as migration 0, as `tailor deploy` does, but only when its schema matches the `0000` snapshot; otherwise the command fails and lists the differences. A namespace that was never deployed also fails.
512
601
  2. Creates a temporary workspace in the same region, organization, and folder as the source, unless `--target-workspace-id` names an existing throwaway workspace.
513
602
  3. Deploys the checkpoint snapshots and writes their checkpoint labels.
514
603
  4. Loads fixture data or clones source records.
@@ -582,12 +671,71 @@ When you run `tailor deploy`, the SDK detects pending migrations (anything past
582
671
  For each pending migration:
583
672
 
584
673
  1. **Pre-migration**: Schema changes that would be breaking are applied in a relaxed form first. A verified in-place field type change keeps its complete previous field contract until Post-migration, including field and table-level hooks or validators changed by the same migration. Newly-required fields are added as optional; fields whose `optional → required` transition is breaking are temporarily kept optional. Fields that are being removed in this migration are temporarily kept on the table so that `migrate.ts` can still read them (for example, to `innerJoin` through a foreign key that is about to be dropped); members removed from a nested field are kept the same way, and the new member of a confirmed nested rename is added as optional and non-unique. For a renamed field, the old field is kept and the new field is added with its constraints relaxed, so the script can read the old field and write the new one. For a renamed table, the new table is created with its full constraints while the old table stays on the namespace until post-migration cleanup, so the script can copy rows between them. Breaking table-level index changes are relaxed the same way: a newly-added unique index is withheld, and an index gaining a unique constraint (or a unique index changing its field set) keeps its previous definition, so `migrate.ts` can resolve duplicates first. Non-breaking changes that are part of the same migration are also applied here.
585
- 2. **Script execution**: If `migrate.ts` exists on disk for this migration, it is bundled and sent to the platform via the script execution API and runs as the configured machine user inside a transaction. The script is hard-required for breaking changes (`diff.requiresMigrationScript`) — deploy fails if the file is missing, unless a `--no-script` acknowledgment was recorded (see [Breaking changes without a script](#breaking-changes-without-a-script)). It is also executed when present for warning-tier diffs — see [Warnings and optional migration scripts](#warnings-and-optional-migration-scripts).
674
+ 2. **Script execution**: If `migrate.ts` exists on disk for this migration, it is bundled and runs on the platform as the configured machine user inside a transaction — or, for a script that exports [`steps`](#splitting-a-migration-into-steps), one transaction per step. The script is hard-required for breaking changes (`diff.requiresMigrationScript`) — deploy fails if the file is missing, unless a `--no-script` acknowledgment was recorded (see [Breaking changes without a script](#breaking-changes-without-a-script)). It is also executed when present for warning-tier diffs — see [Warnings and optional migration scripts](#warnings-and-optional-migration-scripts).
586
675
  3. **Post-migration schema**: Required constraints and the target field definitions are applied. Do not assume that removing a field clears its underlying stored JSON value.
587
676
  4. **Checkpoint and cleanup**: The `sdk-migration` label is bumped to this migration's number, then removed GQL permissions and tables — including the old table left behind by a rename — are deleted. Advancing the checkpoint first prevents a failed checkpoint write from requiring the SDK to recreate irreversibly deleted records.
588
677
 
589
678
  This split is what allows existing rows to be backfilled before the database starts rejecting nulls, and what lets `migrate.ts` traverse foreign-key fields that the same migration removes.
590
679
 
680
+ ### Maintenance mode
681
+
682
+ `maintenanceMode` in `defineConfig` decides whether `tailor deploy` puts each namespace with pending migrations into maintenance mode while it applies them. A table in maintenance mode accepts no GraphQL `create`, `update`, `delete`, or bulk upsert operation and publishes no record events; its `read` setting is left as it is.
683
+
684
+ | Value | Maintenance mode |
685
+ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
686
+ | `false` | None. Nothing is restricted while migrations run. |
687
+ | `"migration"` (default) | From before the first pending migration runs until the last one completes. |
688
+ | `"deploy"` | From before the first pending migration runs until the deploy has applied every other change, including resolvers, executors, and workflows, and has removed the resources it deletes. |
689
+
690
+ A deploy without pending migrations never enters maintenance mode. When `maintenanceMode` is unset, it behaves as `"migration"`, preserving the existing deployment behavior. The value is read from the config like any other, so it can differ per environment (see [Multi-Environment Configuration](../multi-environment.md#varying-config-values-per-environment)):
691
+
692
+ ```typescript
693
+ export default defineConfig({
694
+ name: "my-app",
695
+ maintenanceMode: process.env.APP_ENV === "production" ? "deploy" : false,
696
+ });
697
+ ```
698
+
699
+ **Without maintenance mode**, the SDK does not guarantee what requests that run during the migration observe. A table can still have its [pre-migration shape](#per-migration-phases), with new required fields optional and removed fields present; a client can write rows after `migrate.ts` has already read the table; and the records `migrate.ts` writes publish events to the executors of the previous deploy, which were written for the old shape. Use it only where clients and executors tolerate that.
700
+
701
+ **`"migration"` vs `"deploy"`**: the new resolvers and executors are deployed after the migrations. With `"migration"`, the migrated tables accept requests and publish events again while the resolvers and executors of the previous deploy are still in place. `"deploy"` keeps the tables in maintenance mode until they are replaced.
702
+
703
+ Maintenance mode restricts GraphQL writes and record events only. Resolvers, executors (including scheduled and webhook triggers), and workflows keep running, and the writes they make through Kysely are not blocked.
704
+
705
+ **When a deploy stops early:**
706
+
707
+ - If a migration fails, the tables leave maintenance mode before the error is reported: they return to the settings of the last checkpoint that completed. A migration that did not reach its checkpoint returns existing tables to their previous settings and keeps the tables it created in maintenance mode until a later deploy applies it. The settings are not restored if the checkpoint number or migration history changed concurrently, or if checkpoint ownership cannot be verified. A table left behind by a failed post-checkpoint deletion also stays in maintenance mode for manual recovery.
708
+ - A [multi-step migration](#splitting-a-migration-into-steps) left in progress keeps its tables in maintenance mode until a deploy completes it.
709
+ - With `"deploy"`, if a later part of the deploy fails, the tables leave maintenance mode before the error is reported. When the deploy ends maintenance mode, it leaves the tables of a namespace unchanged if that namespace's checkpoint number or migration history changed concurrently, or if checkpoint ownership cannot be verified, and reports it.
710
+ - If the deploy process is interrupted after a namespace's migrations completed but before maintenance mode ended, that namespace's tables stay in maintenance mode until a later `tailor deploy` releases them when it applies its TailorDB changes, before it replaces resolvers and executors. A namespace whose migrations had not all completed is not released this way: the next deploy resumes a multi-step migration left in progress, and otherwise reports remote schema drift (see [Failure Recovery](#failure-recovery)).
711
+
712
+ #### Keeping maintenance mode short
713
+
714
+ Maintenance mode lasts for the whole migration job, including the time the platform takes to start it, which can far exceed the time `migrate.ts` itself needs. Keep in `migrate.ts` only the work that must be done before the post-migration schema is applied, and move the rest into a workflow that you start after the deploy (for example with [`tailor workflow start`](../cli/workflow.md#workflow-start)).
715
+
716
+ - **Must stay in `migrate.ts`**: anything the post-migration schema enforces or removes — filling a field that becomes required, resolving duplicates before a unique index, converting values for a field type change, and copying data out of a field or table that the migration renames or removes.
717
+ - **Can move to a workflow**: work the post-migration schema does not depend on, such as filling an optional field, recomputing derived values, or cleaning up rows. Requests that run before the workflow finishes see the data in its unfixed state.
718
+
719
+ ### Time spent in maintenance mode
720
+
721
+ When [maintenance mode](#maintenance-mode) is on, `tailor deploy` reports how long the migrating namespaces' tables were restricted, split into phases. With `"deploy"`, the window also includes the remaining deployment changes and ends when their restrictions are lifted. No maintenance report is emitted with `maintenanceMode: false`.
722
+
723
+ | Phase | Covers |
724
+ | ------------------ | ---------------------------------------------------------------------------------------- |
725
+ | `restrict` | Putting the tables into maintenance mode |
726
+ | `pre-migration` | Pre-migration schema changes |
727
+ | `job setup` | Bundling `migrate.ts` and registering it as a job |
728
+ | `waiting to start` | Time when the platform has accepted the job but none of its code runs yet |
729
+ | `running` | Time when the script runs |
730
+ | `job cleanup` | Collecting the script's logs and removing its job, unless its steps run as separate jobs |
731
+ | `post-migration` | Post-migration schema changes, the checkpoint update, and cleanup |
732
+ | `remaining deploy` | With `"deploy"`, applying the remaining deployment changes while tables stay restricted |
733
+ | `restore` | Taking the tables out of maintenance mode |
734
+
735
+ Phases that repeat across several migrations are added up. The report is printed only after the migrations succeed and the tables leave maintenance mode; when a migration fails, deploy reports the error instead (see [Failure Recovery](#failure-recovery)). When deploy resumes a migration that an earlier deploy left in progress, the tables were already in maintenance mode before it started, and the report covers only this deploy. A job can be reported as started on the platform well before its code runs, so deploy tells waiting from running by a line every job of a migration logs before your script: `[tailor-sdk] migration script started`. You may see that line in function execution logs; deploy leaves it out of the logs it prints. A script that exports [`steps`](#splitting-a-migration-into-steps) waits for each step's job to start, and that time counts as `waiting to start` too. Time when deploy cannot tell whether the script is running — because the run or a running job's logs could not be read, or because an older SDK version started the run and it never logs the line — is reported as `waiting to start or running` instead.
736
+
737
+ Deploy checks the run every few seconds, so a boundary between `waiting to start` and `running` can be off by that much. With `--json`, the same breakdown is in `tailordbMaintenance` (see [Deploy JSON result](../cli/application.md#deploy)). Add `--verbose` to also print how long each phase took and every change in the job's status.
738
+
591
739
  ### Schema verification
592
740
 
593
741
  Before running migrations, `apply` performs two checks:
@@ -608,7 +756,7 @@ Namespace: tailordb
608
756
 
609
757
  The error also points you at `migration status`, `migration generate`, `migration sync`, and `migration set` — see [Remote schema drift detected](#remote-schema-drift-detected) for which one applies.
610
758
 
611
- To run the same checks without deploying — plus migration file integrity (numbering, parseable contents, a `migrate.ts` or a recorded `--no-script` acknowledgment for every migration that requires a script, and no unresolved generated normalization review markers):
759
+ To run the same checks without deploying — plus migration file integrity (numbering, parseable contents, a `migrate.ts` or a recorded `--no-script` acknowledgment for every migration that requires a script, and no `TODO()` call left in a `migrate.ts`):
612
760
 
613
761
  ```bash
614
762
  tailor tailordb migration validate
@@ -627,14 +775,14 @@ tailor deploy --no-schema-check
627
775
  ### Example output
628
776
 
629
777
  ```
630
- ℹ Found 2 pending migration(s) to execute.
631
- ℹ Executing 2 pending migration(s)...
632
- ℹ Using machine user: admin-machine-user for namespace 'tailordb'
778
+ ℹ Executing 1 data migration(s)...
633
779
 
634
- ✔ Migration tailordb/0002 completed successfully
635
- ✔ Migration tailordb/0003 completed successfully
780
+ ℹ Using machine user: admin-machine-user for namespace 'tailordb'
781
+ 10:42:31 AM ℹ Migration tailordb/0002 started running after waiting 15m02s for its job to start.
782
+ ✓ Migration tailordb/0002 completed successfully (waiting to start 15m02s, running 1m41s)
636
783
 
637
- ✔ All migrations completed successfully.
784
+ ✔ All data migrations completed successfully.
785
+ ℹ Tables of namespace tailordb were in maintenance mode for 16m56s (restrict 1.2s, pre-migration 3.4s, job setup 2.1s, waiting to start 15m02s, running 1m41s, job cleanup 0.8s, post-migration 4.0s, restore 1.1s).
638
786
  ✔ Successfully applied changes.
639
787
  ```
640
788
 
@@ -728,13 +876,17 @@ Use `tailor tailordb migration rebaseline` rather than combining `migration gene
728
876
 
729
877
  ## Failure Recovery
730
878
 
731
- If the pre-migration phase or `migrate.ts` fails:
879
+ A script that exports `steps` follows different rules once one of its steps has completed: the migration stays in progress and the next deploy resumes it. See [Splitting a migration into steps](#splitting-a-migration-into-steps). Otherwise, if the pre-migration phase or `migrate.ts` fails:
732
880
 
733
881
  - **The transaction rolls back** for that migration's script. Database changes the script made are undone.
734
882
  - **The pre-migration schema changes are rolled back** to the prior checkpoint: tables that already existed are restored to their previous shape, and tables the migration newly introduced are dropped. The workspace is left at its prior checkpoint and prior schema — not half-applied.
735
883
  - The whole `apply` aborts and the checkpoint label is not bumped. Subsequent migrations in the same run do not execute.
736
884
 
737
- The rollback is best-effort per table; if reverting a table fails, a warning is logged and the original migration error is still reported.
885
+ The rollback is best-effort per table; if reverting a table fails, a warning is logged and the original migration error is still reported. The next deploy then stops with remote schema drift on that table. Run `tailor tailordb migration validate` to see which tables differ, return the namespace to its checkpoint with `tailor tailordb migration sync <N>`, where `<N>` is the checkpoint `tailor tailordb migration status` reports, and deploy again. `sync` deletes the tables and fields that are not in that migration's snapshot, together with any rows the failed migration wrote to them; the next deploy runs the failed migration again.
886
+
887
+ If `tailor deploy` cannot tell whether a `main` script ran, for example because the connection dropped while it started or waited for the script, it does not roll anything back: the pre-migration schema changes stay, the namespace's tables stay in [maintenance mode](#maintenance-mode) if it is on, and the deploy reports that it could not confirm the outcome. A later deploy refuses to run the migration while that run is still executing. Run `tailor workflow executions --workflow-name <name>` with the workflow name the error reports until the execution has finished or none is listed. If it succeeded, run `tailor tailordb migration sync <N>` with the migration's own number; otherwise run `tailor tailordb migration sync <N-1>`. Then deploy again. Do not deploy before running sync, even with `--no-schema-check` to skip the drift check: the deploy may run the `main` script again even if the execution succeeded.
888
+
889
+ `migration sync` refuses to run while a migration is in progress. When a steps migration's post-migration schema changes fail and the deploy cannot return its tables to the pre-migration schema, it logs a warning, and the next deploy stops with remote schema drift on those tables. Run `tailor tailordb migration validate` from a checkout of the deployed migration history. If the only issue it reports is that drift, run `tailor deploy --no-schema-check`: the deploy applies the pre-migration schema again, skips the completed steps unless the steps changed, and retries the post-migration schema changes. `--no-schema-check` skips the schema checks of every namespace, so do not use it while `validate` reports any other issue.
738
890
 
739
891
  After a failure:
740
892
 
@@ -791,7 +943,7 @@ The migration script runs in a single transaction. For tables with many rows:
791
943
 
792
944
  - Prefer set-based SQL (`updateTable(...).set(...).where(...)`) over per-row loops.
793
945
  - If a per-row loop is unavoidable, batch by primary key range. Avoid `OFFSET`-based pagination — it scans previously-seen rows on every page.
794
- - Long-running transactions can hit platform timeouts and hold locks. For very large backfills, consider splitting the work across multiple migrations, each operating on a subset.
946
+ - Long-running transactions can hit platform timeouts and hold locks. For very large backfills, consider splitting the work into [steps](#splitting-a-migration-into-steps) or across multiple migrations, each operating on a subset.
795
947
  - Add `LIMIT` and resumability (idempotent `where` clauses) so a re-run after a transient failure converges.
796
948
 
797
949
  ### Migrations that take longer than a minute
@@ -799,12 +951,12 @@ The migration script runs in a single transaction. For tables with many rows:
799
951
  Migration scripts run as workflow jobs, so they are not bound by the 60-second
800
952
  limit that applies to synchronous script execution. `tailor deploy` waits for
801
953
  the migration and reports its logs as usual. Execution time is still bounded —
802
- a workflow job has its own, far longer, execution-time limit.
954
+ a workflow job has its own execution-time limit.
803
955
 
804
- The script still runs in a single transaction, so the guidance above about
805
- locks and transaction size continues to apply — prefer splitting a very large
806
- backfill across several migrations over holding one transaction open for a
807
- long time.
956
+ A `main` script still runs in a single transaction, so the guidance above about
957
+ locks and transaction size continues to apply. When a migration's work does not
958
+ fit one job, split it into [steps](#splitting-a-migration-into-steps): each step
959
+ runs as its own job and transaction.
808
960
 
809
961
  ## Testing Migrations Locally
810
962
 
@@ -844,6 +996,8 @@ Stage the rows each query returns with `mock.enqueueResult(...)` or `mock.setQue
844
996
 
845
997
  These tests need no platform connection and no `tailor-runtime` environment — they run in a plain Vitest setup. Vitest's default `include` pattern already picks up `migrations/**/migrate.test.ts`; if your config narrows `include`, add the migrations directory. The test file is ignored by `tailor deploy` and never ships to the platform.
846
998
 
999
+ A migration generated under [`defaultDateRepresentation`](../configuration.md#application-settings) `"temporal"` or `"date"` records that in its `diff.json`, and deploy runs the script with Temporal or `Date` values for `t.date()`, `t.datetime()`, and `t.time()` fields that omit `as`. The scaffold calls `applyDateRepresentation` from `@tailor-platform/sdk/vitest` with the recorded value, or with `"string"` for a migration that records none, and imports `migrate.ts` after that call, so the test runs the script the same way deploy does even after the setting changes in `tailor.config.ts`. Keep that call when editing the test. The record pins the values at run time only: the types of `t` fields declared inside `migrate.ts` follow the current `tailor.d.ts` like everywhere else, so give such fields an explicit `as` to keep their types and values aligned after the setting changes. The helper selects the representation but does not provide `Temporal` itself, so run such a test in the [`tailor-runtime` Vitest environment](../testing.md#temporal), which installs it on Node.js versions without native Temporal; this is the one case where a migration unit test needs that environment (a migration recorded under `"date"`, or without a record, needs no Temporal and runs in a plain Vitest setup).
1000
+
847
1001
  ### Executing migrate.ts against a local Postgres (PGlite)
848
1002
 
849
1003
  A statement-level test verifies what the script issues, not what it does to data (e.g., whether a `where` clause matches the rows you intended). To run `main` against real rows locally, back Kysely with [`@electric-sql/pglite`](https://pglite.dev/) — an in-memory PostgreSQL — via `createKyselyPGlite` from `@tailor-platform/sdk/vitest`:
@@ -899,7 +1053,7 @@ describe("0005 add required email", () => {
899
1053
 
900
1054
  Pass nested field values as JavaScript objects or arrays of objects, without `JSON.stringify`.
901
1055
 
902
- When the migration was generated with `kyselyTypePlugin({ temporal: true })`, its `db.ts` types date and datetime fields as `Temporal.PlainDate` and `Temporal.Instant`, and top-level time fields as `Temporal.PlainTime`; nested time fields remain strings. Deploy runs the script with those values. Create the instance with `createKyselyPGlite<Unmigrated<Database>>(pglite, { temporal: true })` so PGlite reads top-level temporal columns back the same way; the scaffold adds the option for such migrations. Run the test in the [`tailor-runtime` Vitest environment](../testing.md#temporal), which provides `Temporal`. A migration generated before `temporal` was enabled still runs with `Date` values on deploy, so its test leaves the option out.
1056
+ When the migration was generated with `kyselyTypePlugin({ temporal: true })`, its `db.ts` types date and datetime fields as `Temporal.PlainDate` and `Temporal.Instant`, and top-level time fields as `Temporal.PlainTime`; nested time fields remain strings. Deploy runs the script with those values. Create the instance with `createKyselyPGlite<Unmigrated<Database>>(pglite, { temporal: true })` so PGlite reads top-level temporal columns back the same way; the scaffold adds the option for such migrations. Run the test in the [`tailor-runtime` Vitest environment](../testing.md#temporal), which provides `Temporal`. A migration generated before `temporal` was enabled still runs with `Date` values on deploy, so its test leaves the option out. As in the Kysely mock scaffold, the test calls `applyDateRepresentation` with the migration's recorded `defaultDateRepresentation` (or `"string"`) so `t` date fields parse the way they do on deploy; a migration recorded under `"temporal"` likewise needs the `tailor-runtime` environment for `Temporal`.
903
1057
  Generated migration types use `Record<string, unknown>` for each nested object so scripts can
904
1058
  work with both old and new members during a migration; narrow member values before using them.
905
1059
 
@@ -908,6 +1062,33 @@ Two caveats keep this from replacing a scratch workspace:
908
1062
  - PGlite runs full PostgreSQL, while TailorDB supports [a subset of it](https://docs.tailor.tech/guides/function/accessing-tailordb#supported-sql-queries) — a statement that passes here can still be rejected on deploy.
909
1063
  - `db.pglite.ts` mirrors the column shape, not the platform: hooks, validations, and permissions do not run, and the limits listed under [Real SQL execution with PGlite](../testing.md#real-sql-execution-with-pglite-mocktailordbwithpglite) apply.
910
1064
 
1065
+ ### Testing a multi-step script
1066
+
1067
+ `runMigrationSteps` from `@tailor-platform/sdk/vitest` runs a script's `steps` in the order `tailor deploy` runs them, each through the transaction callback you pass, so a test can check what a failing step leaves behind. When a step throws, it rejects with a `MigrationStepError` naming the failed step and the steps that completed before it. With `pglite` and `Database` set up as in the PGlite test above:
1068
+
1069
+ ```typescript
1070
+ import {
1071
+ createKyselyPGlite,
1072
+ runMigrationSteps,
1073
+ type Unmigrated,
1074
+ } from "@tailor-platform/sdk/vitest";
1075
+ import { steps } from "./migrate";
1076
+
1077
+ const db = createKyselyPGlite<Unmigrated<Database>>(pglite);
1078
+
1079
+ test("backfills every table", async () => {
1080
+ await runMigrationSteps(steps, {
1081
+ transaction: (run) => db.transaction().execute(run),
1082
+ env: { STAGE: "test" },
1083
+ });
1084
+ // assert the rows
1085
+ });
1086
+ ```
1087
+
1088
+ `tailor tailordb migration script <N> --with-test` writes both test scaffolds against `steps` when `migrate.ts` exports `steps`, calling `runMigrationSteps` instead of `main`.
1089
+
1090
+ To test one step on its own, call its `run` with a transaction — and a `{ env }` context when the step takes one: `await db.transaction().execute((trx) => steps.backfillInvoice.run(trx))`. With `createKyselyMock`, pass `transaction: (run) => mock.withTx(run)`.
1091
+
911
1092
  ### Beyond unit tests
912
1093
 
913
1094
  A unit test verifies which statements the script issues; a PGlite test verifies what they do to the rows you staged. Neither runs against your actual data. To cover that:
@@ -932,7 +1113,7 @@ For genuinely different schemas across environments, prefer separate workspaces
932
1113
  1. `tailor tailordb migration status` to see local vs remote.
933
1114
  2. Compare with teammates — has someone applied different migrations?
934
1115
  3. If remote was changed manually, decide whether to update local migrations to match or to use `migration set <N>` to align bookkeeping.
935
- 4. To force the remote schema back to a known snapshot, use `migration sync <N>` (see [`migration sync` Semantics](#migration-sync-semantics)).
1116
+ 4. To force the remote schema back to a known snapshot, use `migration sync <N>` (see [`migration sync` Semantics](#migration-sync-semantics)). If the drift follows a failed deploy that warned it could not restore a table's schema, see [Failure Recovery](#failure-recovery) instead.
936
1117
  5. As a last resort in non-production environments, `--no-schema-check` skips both checks. Do not use this as a routine workaround.
937
1118
 
938
1119
  ### "Remote migration checkpoint is not in the local migration history" error
@@ -981,7 +1162,7 @@ One specific cause is a `migration rebaseline` run on a different environment wh
981
1162
 
982
1163
  **Cause:** Runtime error in your `migrate.ts`, a permission error from the machine user, or a constraint violation when post-migration tightens tables.
983
1164
 
984
- **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.
1165
+ **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. For a script that exports `steps`, the logs of each step are prefixed with its name, and the error names the failed step; the retry resumes from that step (see [Splitting a migration into steps](#splitting-a-migration-into-steps)).
985
1166
 
986
1167
  ### `migrate.ts` not found for a migration that needs one
987
1168
 
@@ -431,16 +431,29 @@ db.string().vector();
431
431
 
432
432
  ```typescript
433
433
  db.int().serial({
434
- start: 0,
434
+ start: 1,
435
435
  maxValue: 100,
436
436
  });
437
437
 
438
438
  db.string().serial({
439
- start: 0,
439
+ start: 1,
440
440
  format: "CUST_%d",
441
441
  });
442
442
  ```
443
443
 
444
+ A serial field cannot be used inside a `db.object()` field.
445
+
446
+ > **Preview:** The `gapless` option is a preview feature. Its behavior and configuration may change before general availability.
447
+
448
+ Set `gapless: true` to number records without gaps. A number is used only by a create that commits, so a create that fails leaves no hole. Numbered creates of the table run one at a time, so use it only when contiguous numbering is required. See the [TailorDB Serial documentation](https://docs.tailor.tech/guides/tailordb/fields#serial) for what the gapless guarantee covers.
449
+
450
+ ```typescript
451
+ db.int().serial({
452
+ start: 1,
453
+ gapless: true,
454
+ });
455
+ ```
456
+
444
457
  ### Common Fields
445
458
 
446
459
  ```typescript
@@ -522,7 +535,7 @@ db.table("User", {
522
535
  - When not specified, `deploy` sets it from the executors taking part in the same run: `true` while one of them uses this table with `recordCreatedTrigger`, `recordUpdatedTrigger`, or `recordDeletedTrigger`, and `false` once none does. Removing the last such trigger turns publishing back off on the next `deploy`
523
536
  - When explicitly set to `false` while an executor taking part in the same run uses this table, `deploy` fails
524
537
  - An executor declared with `disabled: true` never runs, so it does not count as using the table
525
- - While a `deploy` applies pending migrations, every table in the migrating namespace is read-only: create, update, delete, and bulk-upsert operations are disabled while the existing read setting is preserved. Record event publishing is also switched off so a migration script's writes do not reach executors from the previous deploy. After success, the configured settings take effect. If a later migration fails, settings from the last confirmed checkpoint are restored; an uncommitted migration restores existing tables' prior settings and leaves its newly created tables restricted until a successful retry. Restoration is skipped if the checkpoint number or migration history changed concurrently, or if checkpoint ownership cannot be verified. A table left behind by a failed post-checkpoint deletion also remains read-only for manual recovery
538
+ - While a `deploy` applies pending migrations with [maintenance mode](./tailordb-migration.md#maintenance-mode) on, every table in the migrating namespace is restricted: the GraphQL create, update, and delete operations and bulk upsert are disabled, and the table's `read` setting is not switched off for the migration (a table the migration creates uses its configured setting), so nothing writes an intermediate schema and readable tables are not shut down. Record event publishing is also switched off so a migration script's writes do not reach executors from the previous deploy. With `maintenanceMode: false`, these restrictions are not applied. After success, the configured settings take effect. If a later migration fails, settings from the last confirmed checkpoint are restored; an uncommitted migration restores existing tables' prior settings and leaves its newly created tables restricted until a successful retry. A table that already refuses create, update, delete, and bulk upsert and publishes no record events is left as it is. Restoration is skipped if the checkpoint number or migration history changed concurrently, or if checkpoint ownership cannot be verified; the deploy then reports the namespace whose tables stay restricted and points to `tailor tailordb migration status --namespace <namespace>`. When a failed migration or a failed restoration leaves tables restricted, the deploy lists their names, and deploying again releases them once the migration completes. A table that a failed rollback could not delete or revert is the exception: the deploy also lists it, but the next deploy stops with remote schema drift until you delete or repair that table by hand. A table left behind by a failed post-checkpoint deletion also remains read-only for manual recovery, and the deploy does not list it by name
526
539
 
527
540
  **Use cases:**
528
541
 
@@ -567,7 +580,7 @@ db.table("User", {
567
580
 
568
581
  Control which GraphQL operations (`create`, `update`, `delete`, `read`) are exposed for a table. All operations are enabled by default.
569
582
 
570
- While a `deploy` applies pending migrations, every GraphQL operation — `create`, `update`, `delete`, `read`, and bulk upsert — is switched off across the migrating namespace, so nothing reads or writes an intermediate schema. After success, the configured operations take effect. If a later migration fails, operations from the last confirmed checkpoint are restored; an uncommitted migration restores existing tables' prior operations and leaves its newly created tables restricted until a successful retry. Restoration is skipped if the checkpoint number or migration history changed concurrently.
583
+ While a `deploy` applies pending migrations with [maintenance mode](./tailordb-migration.md#maintenance-mode) on, the GraphQL `create`, `update`, and `delete` operations and bulk upsert are switched off across the migrating namespace, so nothing writes an intermediate schema. The `read` operation is not switched off: it keeps the setting the table has when the restrictions begin. With or without maintenance mode, a table that a migration touches follows that migration's schema from its first phase on, so a migration that changes `read` changes it before its script runs; a table no migration touches keeps its setting. A table the migration creates uses its configured `read` setting from the start. After success, the configured operations take effect. If a later migration fails while maintenance mode is on, operations from the last confirmed checkpoint are restored; an uncommitted migration restores existing tables' prior operations and leaves its newly created tables restricted until a successful retry. A table that already refuses create, update, delete, and bulk upsert and publishes no record events is left as it is. Restoration is skipped if the checkpoint number or migration history changed concurrently; the deploy then reports the namespace whose tables stay restricted, and deploying again releases them once the migration completes.
571
584
 
572
585
  ```typescript
573
586
  db.table("Order", {
package/docs/testing.md CHANGED
@@ -211,6 +211,7 @@ What the script cannot reproduce:
211
211
 
212
212
  - Hooks, validations, and permissions do not run. A required field whose value only its own field-level create hook supplies is created nullable, so inserts that omit it succeed; give it a `.default()` if the test reads it back. A field filled by a table-level hook stays `NOT NULL`, as its Kysely type still requires it on insert.
213
213
  - Serial formats are reproduced for a single `%d`, `%x`, or `%X` specifier with an optional zero-padded width; an octal `%o` format fails generation with an error naming the field.
214
+ - `gapless` is not reproduced: the numbers come from a sequence, so an insert that fails still uses up its number.
214
215
  - A date or datetime inside a nested object reads back as a string from `jsonb`, not a `Date`, or a `Temporal.PlainDate` or `Temporal.Instant` under `kyselyTypePlugin({ temporal: true })`, although the platform returns those types.
215
216
  - On a persistent PGlite (`dataDir`), tables created by an earlier run are kept as they were; drop them or start from an empty directory after changing a table definition.
216
217
 
@@ -324,6 +325,7 @@ test("namespace-specific mock", async () => {
324
325
  disabled: false,
325
326
  mfaEnrolled: false,
326
327
  mfaFactorIds: [],
328
+ passwordUpdatedAt: null,
327
329
  });
328
330
 
329
331
  const client = new tailor.idp.Client({ namespace: "my-ns" });
@@ -446,6 +448,8 @@ export default defineConfig({
446
448
 
447
449
  This makes `tailor.secretmanager.getSecret("vault", "key")` return the values defined in your config. You can still override with `mockSecretmanager({ secrets: ... })` in individual tests: a per-test overlay applies only within that test, and the config-loaded secrets remain available to every other test.
448
450
 
451
+ The same `config` option applies the config's [`defaultDateRepresentation`](./configuration.md#application-settings) to tests: with `"temporal"`, `t.date()`, `t.datetime()`, and `t.time()` fields that omit `as` parse to Temporal values in test files and in the modules they import, as they do in deployed functions, and with `"date"` they parse to `Date` values. The config is read once when Vitest starts, in the Vitest process itself rather than under the `tailor-runtime` restrictions, so restart Vitest after changing the setting. A config file that is missing or does not parse fails the run, as does a value other than `"temporal"` or `"date"`. A config that throws while loading, for example on an environment variable only the deploy environment sets, prints a warning and keeps string values for that run, as the secrets loader already tolerates; the warning notes that tests then disagree with `tailor.d.ts` if the config sets `"temporal"` or `"date"`. Tests that call `resolver.body()` directly bypass the SDK's input conversion and must pass values in the representation the body expects. To apply a representation in a test file regardless of the config, for example in a plain Vitest setup or for a [migration test](./services/tailordb-migration.md#testing-migrations-locally) pinned to the value it was generated under, call `applyDateRepresentation` from `@tailor-platform/sdk/vitest` with `"temporal"`, `"date"`, or `"string"` (the representation of a project that leaves the setting unset); it returns a function that restores the previous representation. The helper only selects the representation: parsing such a field needs `Temporal`, which the `tailor-runtime` environment installs when Node.js lacks it, so outside that environment the test must provide a Temporal implementation itself.
452
+
449
453
  ### Per-Project Configuration
450
454
 
451
455
  Apply the runtime environment only to unit tests while keeping other test projects (e.g. e2e) in the default Node.js environment: