Breaking changes
Every consumer-visible break in @createcms/core, per release, with the migration.
Every consumer-visible break in @createcms/core, newest release first, with the
migration for each one. This is the single place that answers "what broke since
version X?" without reading diffs.
The complete release notes (features, fixes, internals) live in the changelog. This file carries only the breaks.
createCMS is pre-1.0, so minor is the breaking channel — see Versioning. Breaking changes are applied cleanly: no compat shims, no aliases. Pin an exact version.
How the pre-0.7 entries were produced. They were reconstructed by hand from the released changesets and diffs of each tag, because no commit in the history up to 0.6.0 carried a marker —
git log --pretty=%s | grep -c '!'andgit log --pretty=%B | grep -c "BREAKING CHANGE"both returned0across all 223 commits. From now on every break is marked at commit time (Commit conventions) and appended here in the same PR that ships it, so this file is maintained forward rather than re-derived. The reconstructed sections are complete with respect to the shipped changesets; a break that was never written down in a changeset would not appear here.
At a glance
| Release | Date | Breaking changes | Database action |
|---|---|---|---|
| 0.6.0 | 2026-08-14 | none (merge behaviour changed — see notes) | — |
| 0.5.0 | 2026-08-03 | client type surface: uploadAssets / replaceAsset hidden; upload error cause dropped | — |
| 0.4.0 | 2026-08-02 | ESM-only + Node ≥ 22.12, Next ≥ 16.2.11, duplicateBlock, publishRelease permission, listMentions, ApprovalOutput.branchId, stricter block-write validation | regenerate + migrate |
| 0.3.0 | 2026-07-11 | getDiff response shape + moved semantics, duplicateRoot permission resource | — |
| 0.2.12 | 2026-07-09 | the large one — required auth, ~30 renames, uniform return shapes, versioned slug, stricter validation | regenerate + migrate |
| 0.2.11 | 2026-06-29 | none | — |
| 0.2.10 | 2026-06-29 | none | — |
| 0.2.9 | 2026-06-29 | media gate re-addressed by asset id; the 0.2.8 image read shape reverted | — |
| 0.2.8 | 2026-06-28 | image properties resolve to { id, slug } on read (reverted in 0.2.9) | — |
| 0.2.7 | 2026-06-28 | getAssetUrlAuthenticated removed | — |
| 0.2.6 | 2026-06-26 | — | recreate (enum value, no backfill) |
| 0.2.5 | 2026-06-26 | createTemplate rejects non-text target properties | recreate (index + columns, no backfill) |
| 0.2.4 | 2026-06-26 | branchProtection.protectMain → protectPublishedBranches (new semantics) | — |
| 0.2.3 | 2026-06-25 | requireApprovalToMerge now defaults to false | recreate (NOT NULL column, no backfill) |
| 0.2.2 | 2026-06-22 | none | — |
| 0.2.1 | 2026-06-20 | none | — |
| 0.2.0 | 2026-06-20 | allowedChildBlocks removed → structure; allowChildren enforced | — |
“Recreate” is the pre-0.2.12 beta stance: those releases changed the schema with no
backfill path, so the documented upgrade was to recreate the database. From 0.2.12
on, schema changes are picked up with createcms generate + a Drizzle migration.
Unreleased
-
@createcms/react:Canvas.Rootrequirescomponentsand renders the store tree. Pass a block map (the same objectcreateBlocksMapreturns is accepted via_components).childrenare no longer the document; they render after the tree (overlay).surface="frame"throws. → Migration:<Canvas.Root components={pageBlocks} />underEditor.Root; move previous children that were the page markup into the map. -
BlockComponentPropsgains a requirededitprop and itspropertiesis no longer| undefined. Every block component the renderer calls now receivesedit({ active, block, field }, plain data —NO_EDIToutside an editor, anchors withedit="preview"). Components that ignoreeditkeep compiling.propertiesis always an object at runtime and is typed that way now. → Migration: code that renders a block component itself (tests, stories, custom canvases) passesedit={NO_EDIT}(from@createcms/core/react/blocks); dropproperties?.optional chaining if you like — it still compiles. -
@createcms/react:useChildren(parentId)returns child refs instead of ids. Each entry is{ id, type, index }(the factory'suseChildrennarrowstypeto the schema's block types), so a row can render its label and position without a second hook. The array keeps its identity until ids or types change. → Migration: replaceuseChildren(id).map((childId) => ...)withuseChildren(id).map((child) => ... child.id ...); nothing else changes.
0.6.0 — 2026-08-14
No API breaks. One behaviour change worth knowing about before you upgrade:
- Three-way merges auto-resolve more. Blocks where source and target changed
disjoint sets of top-level properties now merge automatically (git-style: block
≈ file, property ≈ line) instead of being reported as conflicts. Same-key edits
(including
richText), delete-vs-edit, type changes, and divergent children still conflict.checkConflictsandcreateMergeRequestresponses gainautoMergeableBlockIds(additive). → Migration: none required. A UI that assumed "any two-sided edit on one block is a conflict" will now see fewer conflicts; nothing to change, but review any test that asserted a conflict count.
0.5.0 — 2026-08-03
-
media.uploadAssetsandmedia.replaceAssetno longer exist on the client's inferred types. Both take an in-processbuffer: Blob | ArrayBufferthat cannot survive the client's JSON request body, so a browser call type-checked and then failed at runtime. They are now markedscope: 'server'and the client type builder omits them —client.media.uploadAssets(…)is a compile error. → Migration: browser callers use the signed flows —createSignedUploadfor uploads,createSignedReplace+commitReplace(or theclient.media.useReplaceAsset()hook / theclient.media.replaceStateatom in the vanilla client) for replaces. Server-sidecms.api.media.uploadAssets/replaceAssetare unchanged and still fully callable. This is a type-level guard only: the client's request proxy still dispatches any method name it is handed, so anas anyescape hatch reaches the same route and fails exactly as before. -
Upload/replace failures no longer return the S3 provider error to the client.
uploadAssetsandreplaceAssetused to put the raw provider message ondata.cause, which reached the wire. The full detail is still logged server-side; the client-facing error is trimmed to a status code. → Migration: anything readingerr.data.causefor storage-error detail must read it from your server logs (or youronAPIErrorsink) instead.
0.4.0 — 2026-08-02
The release with the largest blast radius for consumers: it changes how the package is loaded, not just what it exposes.
Packaging and runtime
@createcms/coreis ESM-only. The CommonJS build is gone: themainandmodulefields are removed and everyexportssubpath resolves to ESM only. → Migration: CommonJS projects do not need to convert toimport— Node resolves ESM fromrequire()natively since 22.12, sorequire('@createcms/core')keeps working. Bundlers and tooling that resolved throughmain/moduleneed to respect theexportsmap.- Minimum Node is now 22.12 (
engines.nodewas>=20). This is the direct consequence of dropping the dual build: 22.12 is whererequire(esm)landed. → Migration: raise your runtime (and CI matrix) to Node ≥ 22.12. Dropping the dual build also removed the dual-package hazard — the client layer holds module-level state in nanostores atoms, and a graph that loaded both copies ended up with two independent stores. - The
nextpeer range moved from>=16to>=16.2.11. Every 16.x below that carries nine security advisories, four high (SSRF in Server Actions, an App Router middleware/proxy bypass, SSRF via rewrites); createCMS ships anext/middlewareintegration, so the exposure is real rather than theoretical. → Migration: upgrade Next to ≥ 16.2.11.nextremains an optional peer — projects not using Next.js are unaffected.
API
duplicateBlockrequirestargetParentBlockIdand is child-duplication only. Omitting it used to mint a new top-level root while the endpoint declared onlyblock:create, so a host grantingblock:createand denyingroot:createcould be bypassed through duplication. Its return type is no longer a union (modeis always'child'). → Migration: to duplicate a subtree into a new top-level entry, callduplicateRoot— same arguments, and it has always been guarded asroot:create. Callers that narrowed the old union return can drop the narrowing.publishReleasenow declarespublication:create(wasrelease:update), matchingpublishBranch. → Migration: hosts that grantrelease:updatefor release curation must also grantpublication:createto allow publishing.listMentionsno longer accepts amentionedUserIdquery parameter. It filtered on the caller-supplied value, letting any caller read another user's mention inbox; the filter is now derived from the session user. → Migration: drop the parameter. The endpoint always returns the session user's mentions.ApprovalOutput.branchIdis nowstring | null. It isnullfor approvals whose branch has since been deleted. → Migration: handlenullwherever you readbranchIdoff an approval.
Validation (previously-accepted input now throws)
createBlock.positionisz.number().int().min(0). A negative value used to silently insert near the end of the parent's children (it went straight intoArray.prototype.splice) and a fractional value was truncated. The insert index is now clamped to the child count.targetPropertieson the duplicate paths is parsed against the per-block property schema (buildPropertiesSchema) instead of being cast. Declared constraints —maxLength, numeric ranges, required keys — now apply to duplication too. → Migration for both: writes that relied on the old lax behaviour now fail with a validation error. Fix the payloads.
Database
merge_requests.source_branch_id,merge_requests.target_branch_idandapprovals.branch_idare nullable withON DELETE SET NULL.deleteBranchpreviously threw a raw foreign-key error on any branch that had been merged or had an approval request, so the standard branch → merge request → merge → delete workflow failed at the last step with an opaque 500 and left the branch permanently undeletable. Merge and approval history now survives the branch row. Open merge requests and publications still block deletion. → Migration: runcreatecms generateand apply a Drizzle migration (drizzle-kit generate) after upgrading.
0.3.0 — 2026-07-11
-
getDiff's response shape andmovedsemantics changed. The response is now{ diff, tree, summary, sourceCommitId, targetCommitId, commonAncestorCommitId }, with a newviewquery param ('list' | 'tree' | 'both', default'both').movedis identity-based: a block is flagged only when it was reparented or is a true reorder outlier among surviving siblings (LIS-based). Siblings whose index merely shifted are no longer flagged — inserting one block among N siblings yields one diff entry instead of N+2.childrenReorderedis only set when the relative order of a parent's surviving common children actually changed, never for pure additions/removals.- A draft-slug change on the root yields
slugChange: { from, to }instead of an opaquemodified, and the reserved__slugkey no longer leaks into returned version properties. - Modified entries carry
propertyChanges,typeChange, and word-leveltextDiffsegments;treeis the draft tree with per-nodediffannotations and deleted blocks re-inserted as ghost nodes. → Migration: consumers reading the old top-level array must readdiff(ortree) off the new envelope; requestview: 'list'if you don't want the tree built. Diff UIs that counted on the noisymovedflag will see far fewer entries. New exported types:ChangeType,PropertyChange,TextDiffSegment,MovedInfo,BlockChange,DiffSummary,BlockDiffAnnotation,AnnotatedBlockTreeNode,DiffView.
-
duplicateRoot's permission resource isroot, notblock. It mints a new top-level root — the same privileged actcreateRootguards asroot— but its metadata saidblock, so a consumer grantingblock:createwhile denyingroot:createcould create roots through duplication. → Migration: remapduplicateRootfromblocktorootin anyauthMiddlewarepermission matrix. -
GET boolean query flags decode wire-safely.
z.coerce.boolean()turns the wire string'false'intotrue, so passingfalseover HTTP inverted the flag. The last affected flags —listBranches(isDeletable/hasPublications/hasOpenMergeRequests),listRoots(hasPublications),notifications.list(unreadOnly),getBlockTree(raw/includeReferencePreviews),getPublishedContent(raw),listAssets(unfiled) and the media asset gate (download) — now usewireBooleanSchema. → Migration: none for correct callers; in-process callers passing real booleans were never affected. HTTP callers that had worked around the inversion (passing nothing to mean false, orfalseto mean true) must send the honest value. -
collectRootExecutionPlansis removed. It loaded every root with no limit and serially ran the full per-root planning bundle — an unbounded N+1. It had no callers, was never re-exported from the package entrypoint, and had no./adminsubpath, so it was unreachable from outside the repo. → Migration: none expected. The production entry point,runPruningPass, does the budgeted equivalent (bounded bymaxRoots/maxDurationMs). -
Dependency floor:
fast-xml-parsermoved from^5.4.2to^5.7.0so consumers cannot resolve a version affected by the<5.7.0prototype-pollution / XML-injection advisory.
0.2.12 — 2026-07-09
The largest breaking release in the project's history — shipped as a patch, before the "minor is the breaking channel" rule existed. Eleven passes landed at once. If you are upgrading across this version, budget real time.
Security (fails secure by default)
authMiddlewareis now required.createCMSthrows at construction if it is missing, so auth can never be silently absent. The undocumentedmiddlewarealias was removed. → Migration: to run with no auth on purpose (public/dev), pass the newallowAnonymous()export — byte-identical to the old omitted-middleware behaviour.media.allowedMimeTypesdefaults to an explicit allowlist:image/png,image/jpeg,image/webp,image/gif,video/mp4,video/webm,application/pdf. Noimage/*/video/*wildcards, soimage/svg+xml(a stored-XSS vector) is excluded by default. Uploads carrying file bytes are also checked against their real magic bytes, so a file declaredimage/pngbut containing SVG/HTML is rejected before it reaches storage. → Migration: re-add any format you relied on through the wildcards (avif, heic, mov, …) explicitly.user.exposeColumnsis required when ausertable is configured. It defaulted to every column, leaking password hashes and tokens throughwithUser;resolveUserConfignow throws instead. → Migration: name the safe columns explicitly.- The multi-tenant slug is no longer read from the request body/query by default.
resolveTenantSlugreturns the session-derived fallback and ignores request-supplied slugs unless you opt in with{ allowRequestOverride: true }(intended only behind an admin check). → Migration: opt in explicitly if you were relying on request-supplied tenants. - Approval endpoints no longer accept
reviewedBy/requestedByfrom the request body (spoofable). The actor is derived from the auth context, and a reviewer must match one ofrequestedReviewers. search.queryapplies the same read boundary as normal endpoints. A result is returned only if its underlying entity is visible under the active scope, and notifications are filtered to the requesting user — closing a cross-tenant content leak and a cross-user notification-title leak. → Migration: none, but expect fewer results under a scoping plugin. With no scoping plugin active, behaviour is unchanged.
Renames
Endpoints and parameters:
deleteRoot→archiveRoot(it soft-archives).search.search→search.query, and its paramq→search.notifications.listNotifications/templates.listTemplates/variables.listVariables→.list.media.archiveAsset→archiveAssets;media.updateAssetStatus→updateAssetsStatus.- List direction unified to
sortDirection(wassortOrderonlistAssets/listPublications). moveRootbody paramsortOrder→position(the result field stayssortOrder).- Template endpoints'
id→templateId;listFolders'parentId→parentFolderId. resolveConflicts→applyConflictResolutions;approve→submitApproval;reject→submitRejection.resolveTemplateis now a GET.moveFolderaccepts an explicitnullparent (detach), matchingmoveRoot/moveAssets.
Config and types:
slug.root→slug.prefix;slug.allowRoot→slug.allowIndex.- Permission resources are singular:
'variables'→'variable','templates'→'template'. A new exportedCMSPermissionResourceunion makes a typo in a permission matrix a compile error. - Context types standardised on the
Contextsuffix:CMSMiddlewareCtx,CMSProcedureCtx,CMSHandlerCtx,CMSSystemHandlerCtx→…Context. The duplicateCMSEndpointCtxis removed — useCMSEndpointContext. RootListItem/RootSummaryexpose their own key asid(wasrootId).CMSClientStore.notify(signal)→invalidate(signal)(distinct fromcms.notify, which sends a user notification).cms.$notifications→cms.$InferNotifications.createBlocksRendereris removed — usecreateContentRenderer, orcreateBlocksMap+BlocksRendererfor the low-level path.- Type-prefix cleanups:
ABTest*→AbTest*;GA4Payload/GA4ServerConfig→Ga4Payload/Ga4ServerConfig;CMSEvent→AnalyticsEvent;MultilingualMiddlewareResult→I18nMiddlewareResult; generic paramTCms→TCMS; zod exportnotificationEvent→notificationEventSchema; client typesQueryState/MediaUploadState/MediaUploadFileState/MediaUploadOptions→CMS-prefixed. - The bundled multi-tenant plugin's id changed
multi-tenant→multiTenant(plugin ids must now be valid JS identifiers). - Removed exports:
$InferServerPlugin,InferPluginRealtimeEvents, and theresolveWireNamere-export from the'use client'tracking module (import it from the core entry, server-side). - The vanilla client exposes
media.uploadState(a real atom) instead of a mistypeduseUploadAssets.
Return shapes
Pre-1.0, these replaced the old shapes cleanly — there are no compat shims.
- Commit envelope. Every commit-producing mutation (
createRoot,createBlock,moveBlock,deleteBlock,duplicateBlock,updateBlock,updateBlocks,updateRoot,revertBranch,executeMerge) returns{ commit: { id, message, createdAt, createdBy }, … }— replacing the three divergent keyscommitId/newCommitId/mergeCommitId. A fast-forwardexecuteMergereturns the resulting head commit instead ofmergeCommitId: null.updateBlocksaddschanged: booleanso a no-op save is distinguishable. - Entity envelope.
createBranch/renameBranch→{ branch, isDeletable };createMergeRequest→{ mergeRequest, hasConflicts, conflicts };update/close/reopenMergeRequest→{ mergeRequest };publishBranch→{ publication }(now includingbranchName); approval mutations →{ approval }; comment message mutations →{ message }. getRootHistoryreturns{ commits, total, hasMore }(was{ data, total, offset, limit }).listTemplates/listVariablesgainlimit/offset/searchand return{ …, total, hasMore }.getRoot/getRootBySlugreturn the fullRootListItem(counts + path), not a bare summary.updateAssetStatusreturns{ updated, updatedIds, skipped };deleteTemplate/deleteVariableecho the deleted id ({ templateId }/{ variableId }) instead of{ deleted: true };deleteBlockreturnsdeletedBlockIds;unpublishBranchreturnsunpublishedCommitId/unpublishedAt;uploadAssets/replaceAssetreturn the full asset row.- Type fixes:
getRootHistory.createdAtandcreateSignedUpload.expiresAtare realDates (were an ISO string / epoch-ms) on the server API. getDiff,checkConflictsandcheckDivergenceare now GET (were POST).- The HTTP client types every timestamp as the ISO
stringthe wire actually delivers, via aSerialize<T>mapped type at the client boundary. Server-sidecms.api.*still returns realDates. → Migration: client-side code that treated a timestamp as aDatemust parse it. This makes an existing runtime reality visible to the type checker rather than changing behaviour.
Content and validation
- The root
slugis versioned content.updateRootcommits the slug to the branch instead of writing the globalroots.slug; the live URL only changes when the branch is published, andrevertBranchrestores the slug with the content.createRootleaves the live slug unset until the first publish. Uniqueness is enforced at publish (new typedPUBLISH_SLUG_CONFLICT; an atomicpublishReleaserolls the whole release back on conflict) rather than at draft-write, and slug-change redirects are created at publish, so a never-published slug edit creates no redirect. Two branches editing the slug now produce a normal root-block merge conflict instead of a silent overwrite. → Migration: no schema migration (the draft slug rides an existing JSONB column), but run thebackfillDraftSlugsscript so existing entries have a draft slug to edit and re-publish.parentRootId/moveRootare unchanged — a page move still changes the live URL immediately. updateBlocksenforces structural validation, matching the single-block routes: the posted rootblockIdmust equal the entry root, every written block type must exist, each written block's properties are validated against its type schema, and placement is asserted over the tree. Validation is diff-scoped — new blocks strictly, updated blocks and the root with patch semantics, unchanged blocks untouched.- Stricter property validation:
dateis validated as an ISO datetime; string/number properties honour declarativeminLength/maxLength/pattern/min/max; andimage/referenceids (including inside lists) must exist at write time. → Migration: writes that previously stored an invalid date or a dangling id now throw a validation error instead of failing later at render. - Required links are enforced: a
requiredlink with an empty target (url/rootId/email/phone) is rejected instead of silently passing. executeMergeblocks by default when an OPEN approval request exists on the merge (previously only enforced behind the governance flags), mirroringpublishBranch. A merge with no approval requests is unaffected.deleteCommentMessagereportsoperation: 'delete'to yourauthMiddleware/ permission matrix (was'update'). → Migration: update any permission matrix keyed on'update'for that endpoint.- Reopening a comment thread emits a
threadReopenednotification (was incorrectlythreadResolved). getPublishedContentno longer serialises the A/B control branch twice. The control tree is the top-leveltree/properties, andabTest.variantscarries only the non-control variants. → Migration: read the control snapshot from the top-level tree.RevalidateEvent.slugis renamedstoredSlug, and every URL-shaped value inpathsis a leading-slash path, so using an event value as a Next.js cache tag no longer silently fails to bust. → Migration: readevent.paths(for tags) orevent.storedSlug(the bare slug).- List endpoints parse raw timestamps as UTC (
listRoots, root history,listMergeRequests), fixing an off-by-timezoneDateon non-UTC hosts.
Construction-time throws (previously silent)
createCMSthrows when a collection name collides with a reserved namespace (admin/media/variables/templates/search/notifications/realtime) or a plugin id, or isn't a URL-safe slug. Such a collection used to be silently clobbered and its routes 404'd.- Plugin ids must be valid JS identifiers and every plugin endpoint path must be
/<id>/<method>. - Endpoint-conflict detection spans the full core + collection + plugin surface and
throws on a duplicate path (was plugin-only and a
console.warn). - The config hook
actionis a closed union of endpoint keys (the(string & {})escape hatch is gone), so a misspelled action is a compile error rather than a silent no-op. definePluginSchemais curried —definePluginSchema<CoreTables>()({ … })— so the schema DSL is actually type-checked.
Errors
- State-conflict codes are now
409(were400):BRANCH_NAME_ALREADY_EXISTS,MERGE_REQUEST_ALREADY_EXISTS,ROOT_HAS_CHILDREN,FOLDER_HAS_CONTENT,BRANCH_HAS_PUBLICATIONS,BRANCH_HAS_OPEN_MERGE_REQUESTS. → Migration: update any client branching on the status code. - Removed never-thrown codes:
MERGE_REQUEST_OUTDATED,COMMENT_BODY_REQUIRED,AB_TEST_WEIGHTS_INVALID, and the decorativemedia-optimize$ERROR_CODES. - Network/transport failures (offline/DNS/CORS) are now wrapped in
CMSClientError(status: 0,code: 'NETWORK_ERROR'), soerr instanceof CMSClientErrorholds where it previously didn't. - The Next revalidate webhook returns the standard
{ message }error shape and rejects malformed JSON with a clean400.
Database
- New tables:
scheduled_publications,releases,release_items(scheduled publishing/expiry and atomic multi-page releases). - Index changes: a
(collection, slug)index onrootsfor the publicgetPublishedContentslug lookup; the unusedbv_properties_ginGIN index onblock_versions.propertiesremoved (it could not serve any existing query and only cost write amplification on the highest-churn table). createcms generatenow emitsexport const cmsSchema = pgSchema('cms')(wascms), sotypeof cmsno longer collides with yourcreateCMSinstance. → Migration: runcreatecms generateand add a Drizzle migration. Update any import of the generatedcmsschema export tocmsSchema.
0.2.11 — 2026-06-29
No breaking changes.
0.2.10 — 2026-06-29
No breaking changes. (useNotifications accepts your typed client directly, and
userId became optional — both strictly loosening.)
0.2.9 — 2026-06-29
- The media gate is addressed by asset id:
GET /media/asset/{id}(was the asset slug). The id is exactly what content stores, so<img src="/media/asset/{id}">survives swapping the bytes behind an asset with no content change and no re-render. The redirect is short-cached (max-age=300, no longerimmutable) so a swap propagates within minutes, while the object bytes stay long-cached at the CDN. → Migration: rewrite any hand-built/media/asset/<slug>URL to use the id. A CDN in front of the gate must include the query string in its cache key (the redirect target varies by?format/?w/?download). Two latent gate bugs were fixed alongside — the route was registered with OpenAPI{param}braces (rou3 only matches:param, so every request 404'd before the handler ran) and the handler returned a{ headers, body }object better-call never applies — which is why the gate had never worked over real HTTP. - The 0.2.8 image read-path resolution is reverted. An
imageblock property is a plain asset-id string again on both the write and read paths;resolveImageAssetsand theResolvedImagetype are gone. → Migration: anyone who adopted{ id, slug }in 0.2.8 reads the id directly and builds the gate URL from it.
0.2.8 — 2026-06-28
imageblock properties resolve to{ id, slug }on the rendered read path.getPublishedContent(andgetBlockTreeunlessraw) resolved each stored asset id, exactly aslinkandreferenceproperties are resolved; inresolvedmode animageproperty inferred asResolvedImage({ id, slug } | null). → Migration: reverted one release later, in 0.2.9. If you are upgrading across both, skip it entirely — animagestays a plain id string.
0.2.7 — 2026-06-28
getAssetUrlAuthenticatedis removed (with the internalsignGetObjecthelper). Uploaded objects arepublic-read, so the presigned-GET path was redundant:statusis a visibility flag gating the public gate redirect, not a hard privacy boundary. → Migration: serve assets through the gate, and flip an asset topublicwithupdateAssetStatusto make it servable there. For admin display,listAssets(and thecreateSignedUpload/uploadAssetsresponses) now include a direct objecturlper asset — it bypasses the gate, so it is for internal tooling, not for embedding in content.
0.2.6 — 2026-06-26
- Schema change, no backfill (beta): the
content_usage_targetenum gains'link', for the newlinkblock-property type. → Migration: recreate the database.
0.2.5 — 2026-06-26
createTemplaterejects a template whosepropertyKeydoes not exist on the block type, or is not a text property (string/richText), with the newTEMPLATE_PROPERTY_INVALIDerror. A string template can no longer be seeded into a number/select/image/reference field. → Migration: fix or drop such templates before upgrading.createBlockseeds unset optional properties from templates server-side. Required properties must still be provided (input is validated before defaults apply), caller-provided values always win, andduplicateBlock/updateBlocksdo not re-apply templates.- Templates and variables are scoped by the
i18n(per language) andmulti-tenant(per tenant) plugins, and their core uniqueness moves from a DB unique index to app-level per-scope enforcement. - Schema change, no backfill (beta): the
templatesandvariablesunique indexes are demoted to non-unique lookup indexes, and thei18n/multi-tenantplugins add alanguage/tenant_slugcolumn to both tables. → Migration: recreate the database.
0.2.4 — 2026-06-26
branchProtection.protectMainis removed, replaced bybranchProtection.protectPublishedBranches— and the semantics differ.protectMainprotected the default branch by name; the replacement locks a branch against direct content mutations for exactly as long as it is published. This applies to any published branch (a root can have several at once, e.g. A/B variants), not just the default one, and a never-published branch is freely editable. Enforced by a sharedassertBranchWritableguard on every content-mutation route, includingrevertBranch;createRootis never gated; still throwsPROTECTED_BRANCH(403). → Migration: renameprotectMain: truetoprotectPublishedBranches: true, and note that protection now follows publication state rather than branch name — changes go via another branch + merge, then a re-publish; unpublishing makes the branch directly editable again.
0.2.3 — 2026-06-25
branchProtection.requireApprovalToMergedefaults tofalse. PreviouslyexecuteMergealways required approvals; merges now succeed without approval unless you opt in. → Migration: setrequireApprovalToMerge: trueexplicitly to keep the prior gate. This one is silent — nothing fails, the gate simply stops applying.- Schema change, no backfill (beta): commits gain
branchIdand a NOT NULLorigin_branch_name, sogetRootHistoryattributes each commit to the branch it was created on deterministically instead of via a "nearest branch tip wins" heuristic that mis-attributed shared ancestors. → Migration: recreate the database. There is no migration of existing commit rows.
0.2.2 — 2026-06-22
No breaking changes.
0.2.1 — 2026-06-20
No breaking changes.
0.2.0 — 2026-06-20
allowedChildBlocksis removed, replaced by the collection-levelstructuremap.structureis keyed by parent block name (or the literal'root') with three mutually exclusive modes per entry: open ({}/{ accepts: '*' }), whitelist ({ accepts: ['x'] }, fail-closed), or blacklist ({ excludes: ['x'] }, fail-open). A concreteacceptslist together withexcludesis a compile error, and block names autocomplete against the collection's blocks. → Migration: express the same intent withstructure— e.g.structure: { section: { accepts: ['featureItem'] } }.allowChildrenis enforced on the server: a block withoutallowChildren: truerejects all children (the root always accepts children).createBlock,moveBlockandduplicateBlockenforce the rules and throw the newBLOCK_NOT_ALLOWED_IN_PARENTerror; the visual editor reads the same rules for drop-zone gating, so the two cannot diverge. → Migration: declareallowChildren: trueon every block that holds children.