@comet/agent-features 9.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/package.json +19 -0
- package/rules/coding-guidelines/api-nestjs.instructions.md +107 -0
- package/rules/coding-guidelines/cdn.instructions.md +24 -0
- package/rules/coding-guidelines/general.instructions.md +30 -0
- package/rules/coding-guidelines/git.instructions.md +37 -0
- package/rules/coding-guidelines/kubernetes.instructions.md +59 -0
- package/rules/coding-guidelines/libraries.instructions.md +34 -0
- package/rules/coding-guidelines/naming.instructions.md +39 -0
- package/rules/coding-guidelines/postgresql.instructions.md +40 -0
- package/rules/coding-guidelines/react.instructions.md +102 -0
- package/rules/coding-guidelines/security.instructions.md +44 -0
- package/rules/coding-guidelines/styling.instructions.md +50 -0
- package/rules/coding-guidelines/typescript.instructions.md +50 -0
- package/skills/.gitkeep +0 -0
- package/skills/comet-block/SKILL.md +246 -0
- package/skills/comet-block/references/admin-patterns.md +192 -0
- package/skills/comet-block/references/api-patterns.md +183 -0
- package/skills/comet-block/references/block-loader.md +368 -0
- package/skills/comet-block/references/block-types.md +210 -0
- package/skills/comet-block/references/custom-block-field.md +266 -0
- package/skills/comet-block/references/fixtures.md +436 -0
- package/skills/comet-block/references/image.md +341 -0
- package/skills/comet-block/references/migration.md +597 -0
- package/skills/comet-block/references/registration.md +167 -0
- package/skills/comet-block/references/response-summary.md +102 -0
- package/skills/comet-block/references/rich-text.md +309 -0
- package/skills/comet-block/references/select.md +176 -0
- package/skills/comet-block/references/site-patterns.md +202 -0
- package/skills/comet-mail-react/SKILL.md +541 -0
- package/skills/comet-mail-react/references/components-and-theme.md +441 -0
- package/skills/comet-mail-react/references/layout-patterns.md +315 -0
- package/skills/comet-mail-react/references/styling-and-customization.md +306 -0
- package/skills/comet-minor-update/SKILL.md +191 -0
- package/skills/dev-pm/SKILL.md +100 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
# Block Migration Rules
|
|
2
|
+
|
|
3
|
+
Detailed rules for deciding when a block migration is needed and how to write one. Load this file when an existing block is being edited and the change may affect persisted data.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
Block migrations transform persisted data from an old shape to a new shape when a block's structure changes. They are **API-only** -- Admin and Site layers always work with the latest block shape and never need migration files.
|
|
10
|
+
|
|
11
|
+
Not every block change requires a migration. This document provides a decision matrix for when a migration is needed, a step-by-step workflow for writing one, rules for structuring the migration class, and annotated real-world examples.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Decision Matrix
|
|
16
|
+
|
|
17
|
+
Use this table to determine whether a migration is needed for a given change.
|
|
18
|
+
|
|
19
|
+
| Change type | Migration needed? | Reason |
|
|
20
|
+
| ------------------------------------------------------------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
21
|
+
| **Add a required field** (string, number, enum, boolean) | Yes | Existing data lacks the field; the API validator rejects it. A default value must be injected. |
|
|
22
|
+
| **Add a required child block** | Yes | Existing data lacks the field; the block expects a structural object (e.g., empty RichTextBlock). |
|
|
23
|
+
| **Add an optional/nullable field** | No | `@IsUndefinable()` + `@BlockField({ nullable: true })` allows `undefined`. Existing data is valid. |
|
|
24
|
+
| **Remove a field or child block** | Recommended | Old data still contains the field. A migration keeps persisted data clean (not strictly required). |
|
|
25
|
+
| **Change a field's type** (e.g., string → RichText) | Yes | Existing data has the old type; the new block definition rejects it. |
|
|
26
|
+
| **Rename a field** | Yes | Existing data uses the old key name; the new key would be empty. |
|
|
27
|
+
| **Add enum values** | No | Old data uses existing valid values; new options are simply available going forward. |
|
|
28
|
+
| **Remove enum values** | Depends | If persisted data contains removed values, a migration must map them to a valid replacement. |
|
|
29
|
+
| **Change an enum's default** | No | The default only applies to new blocks in the Admin; existing data already has a stored value. |
|
|
30
|
+
| **Add a block type to a BlocksBlock/OneOfBlock** | No | New types are additive; existing data never references them. |
|
|
31
|
+
| **Remove a block type from a BlocksBlock/OneOfBlock** | Recommended | Orphaned entries persist in the data. A migration filters them out for cleanliness. |
|
|
32
|
+
| **Change a number field's min/max constraints** | Depends | If existing values fall outside the new range, a migration must clamp them. |
|
|
33
|
+
| **Change a boolean's default** | No | Existing data already has an explicit `true`/`false` value stored. |
|
|
34
|
+
| **Wrap a block in a new structure** (e.g., composite → one-of, composite → list, single block → blocks-block) | Yes | The persisted data has the old block's flat shape; the new wrapper expects a different structural envelope (e.g., `activeType` + `attachedBlocks` for one-of, or an array of items for list). |
|
|
35
|
+
| **Replace a block entirely** (swap one block for a different block with a different data shape) | Yes | The persisted data contains the old block's shape; the new block definition rejects it. A migration must transform the old shape into the new one (or provide an empty default). |
|
|
36
|
+
|
|
37
|
+
**Rule of thumb:** if existing persisted data would fail validation or render incorrectly after the change, a migration is needed. When in doubt, create a migration -- it is always the safer choice.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Workflow: Writing a Migration
|
|
42
|
+
|
|
43
|
+
Follow these steps when a migration is needed.
|
|
44
|
+
|
|
45
|
+
### 1. Create the migration file
|
|
46
|
+
|
|
47
|
+
Place the file in a `migrations/` directory next to the block file. Use the naming convention `{version}-{kebab-case-description}.migration.ts`.
|
|
48
|
+
|
|
49
|
+
If the block already has migrations, the version is one higher than the current highest `toVersion`. If this is the first migration, the version is `1`.
|
|
50
|
+
|
|
51
|
+
### 2. Define the `From` and `To` interfaces
|
|
52
|
+
|
|
53
|
+
Only include fields **relevant to this migration**. Use `unknown` for field values that pass through unchanged.
|
|
54
|
+
|
|
55
|
+
| Pattern | `From` | `To` |
|
|
56
|
+
| --------------------- | ------------------------------------ | ------------------------------------------- |
|
|
57
|
+
| Adding a field | All current fields as `unknown` | `extends From` with the new field |
|
|
58
|
+
| Removing a field | Includes the field being removed | Omits the removed field |
|
|
59
|
+
| Changing a field type | Field typed with old value structure | Field typed with new value structure |
|
|
60
|
+
| Renaming a field | Includes old field name | Includes new field name, omits old |
|
|
61
|
+
| No shape change | `type To = From` | Same shape (filtering entries, moving data) |
|
|
62
|
+
|
|
63
|
+
For detailed rules on structuring these interfaces, see [From/To Interface Rules](#fromto-interface-rules) below.
|
|
64
|
+
|
|
65
|
+
### 3. Implement the `migrate` method
|
|
66
|
+
|
|
67
|
+
The `migrate` method receives the old data (without the internal `$$version` field) and returns the new shape:
|
|
68
|
+
|
|
69
|
+
- Always spread `...from` to preserve fields not involved in the migration.
|
|
70
|
+
- Use plain literals for default values -- **never** import enums, block classes, or other application code. Migrations must be self-contained so they don't break when the application code changes later.
|
|
71
|
+
- For RichTextBlock empty defaults, use the inline DraftJS structure: `{ draftContent: { blocks: [], entityMap: {} } }`.
|
|
72
|
+
- For child block empty defaults, use `{}` (an empty object representing the block's default state).
|
|
73
|
+
|
|
74
|
+
### 4. Register the migration in the block file
|
|
75
|
+
|
|
76
|
+
- **First migration:** Convert the `createBlock` third argument from a plain string to an options object with `name` and `migrate`.
|
|
77
|
+
- **Subsequent migration:** Import the new migration class, append it to the `typeSafeBlockMigrationPipe` array, and increment `version`.
|
|
78
|
+
|
|
79
|
+
The `version` in the options object must always equal the highest `toVersion` among all migrations. See [Registering Migrations](#registering-migrations) for full patterns, code examples, and version numbering rules.
|
|
80
|
+
|
|
81
|
+
### 5. Update the block classes
|
|
82
|
+
|
|
83
|
+
Apply the structural change to `BlockData` and `BlockInput` so the API accepts the new shape going forward. Then update the Admin and Site layers.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Migration Class Template
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { BlockMigration, type BlockMigrationInterface } from "@comet/cms-api";
|
|
91
|
+
|
|
92
|
+
interface From {
|
|
93
|
+
// Fields that exist before this migration. Use `unknown` for values.
|
|
94
|
+
existingField: unknown;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface To extends From {
|
|
98
|
+
// New field being added (for an "add field" migration).
|
|
99
|
+
newField: unknown;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class AddNewFieldMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface {
|
|
103
|
+
public readonly toVersion = 1;
|
|
104
|
+
|
|
105
|
+
protected migrate(from: From): To {
|
|
106
|
+
return {
|
|
107
|
+
...from,
|
|
108
|
+
newField: "defaultValue",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
This template shows the most common pattern — adding a field. Adapt the `From` and `To` interfaces for other change types using the [From/To Interface Rules](#fromto-interface-rules) below.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## From/To Interface Rules
|
|
119
|
+
|
|
120
|
+
- Only include fields **relevant to this migration** in `From` and `To`. Fields that pass through unchanged do not need to appear — the `...from` spread preserves them. For included fields whose values are not inspected, use `unknown` as the type.
|
|
121
|
+
- **`To extends From`** when adding a field — the new shape is the old shape plus the addition.
|
|
122
|
+
- **`type To = From`** when the structural shape does not change — e.g., filtering entries from an inner array or transforming data within existing fields. With all fields typed as `unknown`, the interfaces are structurally identical.
|
|
123
|
+
- **Define `To` independently** (not extending `From`) when removing or renaming fields, because the old and new shapes have different keys.
|
|
124
|
+
- **Never import application types** into `From` or `To`. Use `unknown` for field values and inline type literals for nested structures. Importing block classes, enums, or shared interfaces couples the migration to code that may change later.
|
|
125
|
+
|
|
126
|
+
See the table in [workflow step 2](#2-define-the-from-and-to-interfaces) for a per-pattern quick reference. For annotated code examples of each pattern, see the [Annotated Examples](#annotated-examples) section below.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Common Default Values
|
|
131
|
+
|
|
132
|
+
Use these defaults when a migration injects a new field into existing data.
|
|
133
|
+
|
|
134
|
+
| New field type | Default value in migration |
|
|
135
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
136
|
+
| String (required) | `""` |
|
|
137
|
+
| Enum | The default enum value as a plain string (e.g., `"primary"`) |
|
|
138
|
+
| Number | The logical default (e.g., `50`) |
|
|
139
|
+
| Boolean | `false` |
|
|
140
|
+
| RichTextBlock (empty) | `{ draftContent: { blocks: [], entityMap: {} } }` |
|
|
141
|
+
| RichTextBlock (from string) | `{ draftContent: { blocks: [{ key: uuid(), text: oldValue, type: "unstyled", depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} }], entityMap: {} } }` |
|
|
142
|
+
| Child block (empty) | `{}` -- an empty object representing the block's default state |
|
|
143
|
+
| OneOfBlock (empty) | `{ activeType: "firstType", attachedBlocks: [{ type: "firstType", props: {} }] }` |
|
|
144
|
+
|
|
145
|
+
**Notes:**
|
|
146
|
+
|
|
147
|
+
- **RichTextBlock (from string):** When converting a string field to a RichTextBlock, wrap the existing string value in the DraftJS content structure shown above. Import `uuid` via `import { v4 as uuid } from "uuid"` and use `uuid()` to generate the block key. Fall back to `""` if the old string may be `undefined` (e.g., `text: from.myField ?? ""`). See [Example 2](#example-2-replacing-a-string-field-with-a-richtextblock) for the full pattern.
|
|
148
|
+
- **OneOfBlock (empty):** Replace `"firstType"` with the actual key of the first supported block type (e.g., `"image"` if `supportedBlocks` starts with `image: DamImageBlock`). The `props: {}` represents the child block's empty default state.
|
|
149
|
+
- **Child block (empty):** Most child blocks use `{}` as their empty default. For RichTextBlock children, use the RichTextBlock (empty) pattern above instead.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Registering Migrations
|
|
154
|
+
|
|
155
|
+
After writing the migration class, register it in the block's `createBlock` call so the API knows to run it against persisted data.
|
|
156
|
+
|
|
157
|
+
### First Migration on a Block
|
|
158
|
+
|
|
159
|
+
When a block has **no existing migrations**, the `createBlock` third argument is a plain name string. Convert it to an options object with `name` and `migrate`:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// Before (no migrations)
|
|
163
|
+
export const MyBlock = createBlock(MyBlockData, MyBlockInput, "My");
|
|
164
|
+
|
|
165
|
+
// After (first migration added)
|
|
166
|
+
import { typeSafeBlockMigrationPipe } from "@comet/cms-api";
|
|
167
|
+
import { AddSubtitleMigration } from "./migrations/1-add-subtitle.migration";
|
|
168
|
+
|
|
169
|
+
export const MyBlock = createBlock(MyBlockData, MyBlockInput, {
|
|
170
|
+
name: "My",
|
|
171
|
+
migrate: {
|
|
172
|
+
version: 1,
|
|
173
|
+
migrations: typeSafeBlockMigrationPipe([AddSubtitleMigration]),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The `name` value must be the same string that was previously used as the third argument.
|
|
179
|
+
|
|
180
|
+
### Adding a Subsequent Migration
|
|
181
|
+
|
|
182
|
+
When a block **already has migrations**, three changes are needed:
|
|
183
|
+
|
|
184
|
+
1. **Import** the new migration class.
|
|
185
|
+
2. **Append** it to the end of the `typeSafeBlockMigrationPipe` array.
|
|
186
|
+
3. **Increment** `version` to match the new migration's `toVersion`.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { typeSafeBlockMigrationPipe } from "@comet/cms-api";
|
|
190
|
+
import { AddSubtitleMigration } from "./migrations/1-add-subtitle.migration";
|
|
191
|
+
import { ReplaceTextWithRichTextMigration } from "./migrations/2-replace-text-with-rich-text.migration";
|
|
192
|
+
|
|
193
|
+
export const MyBlock = createBlock(MyBlockData, MyBlockInput, {
|
|
194
|
+
name: "My",
|
|
195
|
+
migrate: {
|
|
196
|
+
version: 2, // incremented from 1 to 2
|
|
197
|
+
migrations: typeSafeBlockMigrationPipe([AddSubtitleMigration, ReplaceTextWithRichTextMigration]),
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### `typeSafeBlockMigrationPipe`
|
|
203
|
+
|
|
204
|
+
`typeSafeBlockMigrationPipe` accepts an ordered array of migration classes and enforces type-safe chaining at compile time (each migration's `To` must be compatible with the next migration's `From`). Import it from `@comet/cms-api`.
|
|
205
|
+
|
|
206
|
+
Rules for the migrations array:
|
|
207
|
+
|
|
208
|
+
- **Order by `toVersion` ascending** — the migration with `toVersion = 1` comes first, `toVersion = 2` second, and so on.
|
|
209
|
+
- **No gaps** — version numbers must be sequential (1, 2, 3, ...).
|
|
210
|
+
- **Maximum 20 migrations** per pipe (TypeScript inference limit).
|
|
211
|
+
|
|
212
|
+
### Version Numbering Rules
|
|
213
|
+
|
|
214
|
+
| Property | Must equal |
|
|
215
|
+
| ------------------------------------------ | --------------------------------------------------------- |
|
|
216
|
+
| `migrate.version` in `createBlock` options | The highest `toVersion` among all migrations in the array |
|
|
217
|
+
| Each migration's `toVersion` | Its sequential position (first = 1, second = 2, etc.) |
|
|
218
|
+
|
|
219
|
+
If a block has three migrations with `toVersion` values 1, 2, and 3, then `migrate.version` must be `3`.
|
|
220
|
+
|
|
221
|
+
### Block Factory Variants
|
|
222
|
+
|
|
223
|
+
Blocks created with factory helpers (e.g., `ColumnsBlockFactory.create`) accept the same name-or-options argument as `createBlock`. The registration pattern is identical — replace the name string with an options object:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// Before
|
|
227
|
+
export const ColumnsBlock = ColumnsBlockFactory.create(
|
|
228
|
+
{
|
|
229
|
+
/* ... */
|
|
230
|
+
},
|
|
231
|
+
"Columns",
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// After
|
|
235
|
+
export const ColumnsBlock = ColumnsBlockFactory.create(
|
|
236
|
+
{
|
|
237
|
+
/* ... */
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: "Columns",
|
|
241
|
+
migrate: {
|
|
242
|
+
version: 1,
|
|
243
|
+
migrations: typeSafeBlockMigrationPipe([MyMigration]),
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
For full annotated examples of migration registration, see the [Annotated Examples](#annotated-examples) section below.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Important Rules
|
|
254
|
+
|
|
255
|
+
1. **Never import application code in migrations.** Use plain string literals, inline objects, and hardcoded values. Migrations must remain self-contained so they don't break when enums, block classes, or constants are renamed or removed later.
|
|
256
|
+
2. **Each migration file exports exactly one class.** The class name is the PascalCase version of the file's kebab-case description plus a `Migration` suffix.
|
|
257
|
+
3. **Version numbers must be sequential.** The first migration has `toVersion = 1`, the second has `toVersion = 2`, and so on. Gaps are not allowed.
|
|
258
|
+
4. **The block's `version` must equal the highest `toVersion`.** In the `createBlock` options, `migrate.version` must match the `toVersion` of the last migration in the array.
|
|
259
|
+
5. **Migrations are ordered by `toVersion` (ascending)** in the `typeSafeBlockMigrationPipe` array.
|
|
260
|
+
6. **Migrations are API-only.** Admin and Site layers do not need migration files -- they always work with the latest block shape.
|
|
261
|
+
7. **`From` and `To` interfaces should be minimal.** Only include fields relevant to the migration. Use `unknown` for field values that are not inspected or transformed.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Annotated Examples
|
|
266
|
+
|
|
267
|
+
### Example 1: Adding a New Required Field with a Default Value
|
|
268
|
+
|
|
269
|
+
**Scenario:** A `HeadlineBlock` has three properties -- `headline` (RichTextBlock), `eyebrow` (string), and `textPosition` (enum). A new `background` enum field must be added. Existing content must default to `"LightGrey"`.
|
|
270
|
+
|
|
271
|
+
**Migration file** (`migrations/1-add-background-option.migration.ts`):
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import { BlockMigration, type BlockMigrationInterface } from "@comet/cms-api";
|
|
275
|
+
|
|
276
|
+
interface From {
|
|
277
|
+
headline: unknown;
|
|
278
|
+
eyebrow: unknown;
|
|
279
|
+
textPosition: unknown;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface To extends From {
|
|
283
|
+
background: unknown;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export class AddBackgroundOptionMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface {
|
|
287
|
+
public readonly toVersion = 1;
|
|
288
|
+
|
|
289
|
+
protected migrate(from: From): To {
|
|
290
|
+
return {
|
|
291
|
+
...from,
|
|
292
|
+
background: "LightGrey",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
**Registration:**
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import { typeSafeBlockMigrationPipe } from "@comet/cms-api";
|
|
302
|
+
import { AddBackgroundOptionMigration } from "./migrations/1-add-background-option.migration";
|
|
303
|
+
|
|
304
|
+
export const HeadlineBlock = createBlock(HeadlineBlockData, HeadlineBlockInput, {
|
|
305
|
+
name: "Headline",
|
|
306
|
+
migrate: {
|
|
307
|
+
version: 1,
|
|
308
|
+
migrations: typeSafeBlockMigrationPipe([AddBackgroundOptionMigration]),
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
### Example 2: Replacing a String Field with a RichTextBlock
|
|
316
|
+
|
|
317
|
+
**Scenario:** The `eyebrow` field on `HeadlineBlock` must be changed from a plain string to a `RichTextBlock`. Existing string content must be preserved inside the new structure.
|
|
318
|
+
|
|
319
|
+
**Migration file** (`migrations/2-replace-text-with-rich-text-block.migration.ts`):
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
import { BlockMigration, type BlockMigrationInterface } from "@comet/cms-api";
|
|
323
|
+
import { v4 as uuid } from "uuid";
|
|
324
|
+
|
|
325
|
+
interface From {
|
|
326
|
+
headline: unknown;
|
|
327
|
+
eyebrow: unknown;
|
|
328
|
+
textPosition: unknown;
|
|
329
|
+
background: unknown;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
type To = From;
|
|
333
|
+
|
|
334
|
+
export class ReplaceTextWithRichTextMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface {
|
|
335
|
+
public readonly toVersion = 2;
|
|
336
|
+
|
|
337
|
+
protected migrate(from: From): To {
|
|
338
|
+
return {
|
|
339
|
+
...from,
|
|
340
|
+
eyebrow: {
|
|
341
|
+
draftContent: {
|
|
342
|
+
blocks: [
|
|
343
|
+
{
|
|
344
|
+
key: uuid(),
|
|
345
|
+
text: from.eyebrow ?? "",
|
|
346
|
+
type: "unstyled",
|
|
347
|
+
depth: 0,
|
|
348
|
+
inlineStyleRanges: [],
|
|
349
|
+
entityRanges: [],
|
|
350
|
+
data: {},
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
entityMap: {},
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
`type To = From` because the field name stays the same -- only its runtime value changes. With all fields typed as `unknown`, the interfaces are structurally identical.
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
### Example 3: Adding a New Child Block with an Empty Default
|
|
366
|
+
|
|
367
|
+
**Scenario:** A `ContactFormBlock` with one property (`contactFormDisclaimer`) needs a new `text` child block (RichTextBlock). It must default to an empty RichTextBlock so existing content renders without changes.
|
|
368
|
+
|
|
369
|
+
**Migration file** (`migrations/1-add-text-to-contact-form.migration.ts`):
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
import { BlockMigration, type BlockMigrationInterface } from "@comet/cms-api";
|
|
373
|
+
|
|
374
|
+
interface From {
|
|
375
|
+
contactFormDisclaimer: unknown;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
interface To extends From {
|
|
379
|
+
text: unknown;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export class AddTextToContactFormMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface {
|
|
383
|
+
public readonly toVersion = 1;
|
|
384
|
+
|
|
385
|
+
protected migrate(from: From): To {
|
|
386
|
+
return {
|
|
387
|
+
...from,
|
|
388
|
+
text: {
|
|
389
|
+
draftContent: {
|
|
390
|
+
blocks: [],
|
|
391
|
+
entityMap: {},
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
### Example 4: Removing a Block Type from a Blocks-Block
|
|
402
|
+
|
|
403
|
+
**Scenario:** The `contactForm` block type has been removed from `ColumnsContentBlock`'s `supportedBlocks`, but existing data may still contain `contactForm` entries. A migration filters out orphaned entries.
|
|
404
|
+
|
|
405
|
+
The overall shape does not change -- `From` and `To` are identical. The migration only filters entries from an inner array.
|
|
406
|
+
|
|
407
|
+
**Migration file** (`migrations/1-remove-contact-form-from-columns-block.migration.ts`):
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
import { BlockMigration, type BlockMigrationInterface } from "@comet/cms-api";
|
|
411
|
+
|
|
412
|
+
type From = {
|
|
413
|
+
columns: Array<{
|
|
414
|
+
props: {
|
|
415
|
+
blocks: Array<{
|
|
416
|
+
type: string;
|
|
417
|
+
}>;
|
|
418
|
+
};
|
|
419
|
+
}>;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
type To = From;
|
|
423
|
+
|
|
424
|
+
export class RemoveContactFormFromColumnsBlockMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface {
|
|
425
|
+
public readonly toVersion = 1;
|
|
426
|
+
|
|
427
|
+
protected migrate(from: From): To {
|
|
428
|
+
const columnsWithoutContactForm = from.columns.map((column) => ({
|
|
429
|
+
...column,
|
|
430
|
+
props: {
|
|
431
|
+
...column.props,
|
|
432
|
+
blocks: column.props.blocks.filter((block) => block.type !== "contactForm"),
|
|
433
|
+
},
|
|
434
|
+
}));
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
...from,
|
|
438
|
+
columns: columnsWithoutContactForm,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
**Registration with ColumnsBlockFactory:**
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
import { ColumnsBlockFactory, typeSafeBlockMigrationPipe } from "@comet/cms-api";
|
|
448
|
+
import { RemoveContactFormFromColumnsBlockMigration } from "./migrations/1-remove-contact-form-from-columns-block.migration";
|
|
449
|
+
|
|
450
|
+
export const ColumnsBlock = ColumnsBlockFactory.create(
|
|
451
|
+
{ contentBlock: ColumnsContentBlock, layouts: [{ name: "9-9" }, { name: "12-6" }] },
|
|
452
|
+
{
|
|
453
|
+
name: "Columns",
|
|
454
|
+
migrate: {
|
|
455
|
+
version: 1,
|
|
456
|
+
migrations: typeSafeBlockMigrationPipe([RemoveContactFormFromColumnsBlockMigration]),
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
);
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
---
|
|
463
|
+
|
|
464
|
+
### Example 5: Renaming a Field
|
|
465
|
+
|
|
466
|
+
**Scenario:** A `VideoBlock`'s `thumbnail` field must be renamed to `previewImage`. The image data must be moved to the new key.
|
|
467
|
+
|
|
468
|
+
**Migration file** (`migrations/1-thumbnail-to-preview-image.migration.ts`):
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
import { BlockMigration, type BlockMigrationInterface } from "@comet/cms-api";
|
|
472
|
+
|
|
473
|
+
interface From {
|
|
474
|
+
thumbnail: unknown;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
interface To {
|
|
478
|
+
previewImage: unknown;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export class ThumbnailToPreviewImageMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface {
|
|
482
|
+
public readonly toVersion = 1;
|
|
483
|
+
|
|
484
|
+
protected migrate(from: From): To {
|
|
485
|
+
const { thumbnail, ...remainingFields } = from as From & Record<string, unknown>;
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
...remainingFields,
|
|
489
|
+
previewImage: thumbnail,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
`To` does **not** extend `From` because the old field is removed -- the two interfaces share no field names.
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
### Example 6: Multiple Sequential Migrations on One Block
|
|
500
|
+
|
|
501
|
+
**Scenario:** An `ArticleBlock` starts with `heading`, `image`, `description`. Three separate changes are made over time:
|
|
502
|
+
|
|
503
|
+
1. Add `colorScheme` enum field
|
|
504
|
+
2. Add `subtitle` RichTextBlock child block
|
|
505
|
+
3. Rename `heading` to `title`
|
|
506
|
+
|
|
507
|
+
Each migration's `From` reflects the block shape at the point where that migration runs, not the original or final shape.
|
|
508
|
+
|
|
509
|
+
**Migration 1** (`migrations/1-add-color-scheme.migration.ts`):
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
interface From {
|
|
513
|
+
heading: unknown;
|
|
514
|
+
image: unknown;
|
|
515
|
+
description: unknown;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
interface To extends From {
|
|
519
|
+
colorScheme: unknown;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export class AddColorSchemeMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface {
|
|
523
|
+
public readonly toVersion = 1;
|
|
524
|
+
|
|
525
|
+
protected migrate(from: From): To {
|
|
526
|
+
return { ...from, colorScheme: "light" };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**Migration 2** (`migrations/2-add-subtitle.migration.ts`) -- `From` includes `colorScheme` (added by migration 1):
|
|
532
|
+
|
|
533
|
+
```ts
|
|
534
|
+
interface From {
|
|
535
|
+
heading: unknown;
|
|
536
|
+
image: unknown;
|
|
537
|
+
description: unknown;
|
|
538
|
+
colorScheme: unknown;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
interface To extends From {
|
|
542
|
+
subtitle: unknown;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export class AddSubtitleMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface {
|
|
546
|
+
public readonly toVersion = 2;
|
|
547
|
+
|
|
548
|
+
protected migrate(from: From): To {
|
|
549
|
+
return { ...from, subtitle: { draftContent: { blocks: [], entityMap: {} } } };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
**Migration 3** (`migrations/3-rename-heading-to-title.migration.ts`) -- only lists fields relevant to the rename:
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
interface From {
|
|
558
|
+
heading: unknown;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
interface To {
|
|
562
|
+
title: unknown;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export class RenameHeadingToTitleMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface {
|
|
566
|
+
public readonly toVersion = 3;
|
|
567
|
+
|
|
568
|
+
protected migrate(from: From): To {
|
|
569
|
+
const { heading, ...remainingFields } = from as From & Record<string, unknown>;
|
|
570
|
+
return { ...remainingFields, title: heading };
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
**Registration:**
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
export const ArticleBlock = createBlock(ArticleBlockData, ArticleBlockInput, {
|
|
579
|
+
name: "Article",
|
|
580
|
+
migrate: {
|
|
581
|
+
version: 3,
|
|
582
|
+
migrations: typeSafeBlockMigrationPipe([AddColorSchemeMigration, AddSubtitleMigration, RenameHeadingToTitleMigration]),
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
**Runtime behaviour:** A document at version 0 runs all three migrations in sequence. A document at version 1 skips migration 1 and runs 2 and 3. A document at version 2 runs only migration 3. A document at version 3 runs no migrations.
|
|
588
|
+
|
|
589
|
+
---
|
|
590
|
+
|
|
591
|
+
## Cross-references
|
|
592
|
+
|
|
593
|
+
| Topic | File |
|
|
594
|
+
| ------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
595
|
+
| Editing workflow (when to check for migration need) | [SKILL.md — Editing an existing block](../SKILL.md#editing-an-existing-block) |
|
|
596
|
+
| Enum field patterns (relevant to enum-related migrations) | [select.md](select.md) |
|
|
597
|
+
| RichText structure (relevant to string → RichText migrations) | [rich-text.md](rich-text.md) |
|