Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions server/repositories/data/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { DbClient } from '../../db/client'
import { countDataRows } from './rows/read'
import { normalizeRouteBase } from '@core/templates/templateMatching'
import { buildPostTypeDefaultFields, normalizeDataTableFields } from '@core/data/fields'
import { POST_TYPE_MANDATORY_FIELD_IDS } from '@core/data/schemas'
import type {
DataField,
DataTable,
Expand Down Expand Up @@ -201,6 +202,51 @@ function withPostTypeBuiltIns(kind: DataTableKind | undefined, fields: DataField
return [...missing, ...fields]
}

/**
* The same invariant, held across a PATCH.
*
* `fields` is a whole-list replacement, and the natural way to add one custom
* field is to send the custom field list — which is exactly the payload that
* drops every built-in. Nothing downstream complains: the table saves, the
* rows keep their `slug` CELL, and then the next save of any row recomputes
* the routable slug through `slugForTable`, which returns '' for a table with
* no `slug` field. Every entry in the post type quietly loses its public route.
*
* Only `title` and `slug` are held. The other built-ins (body, featured media,
* the two SEO fields) are deliberately removable — the Data inspector offers
* them back under "missing optional built-ins" — and the field editor already
* marks the mandatory two undeletable client-side. This is the same rule
* enforced where it cannot be bypassed.
*
* A patch may still relabel or retype `title`/`slug`, and may reorder
* anything; it just cannot drop them by omission. The existing definition is
* preserved rather than reseeded from the defaults, so a legitimately
* customised `title` (relabelled "Recipe name") survives.
*
* Membership is decided by field ID, not by the `builtIn` flag: on create,
* "caller-supplied fields win on id collision" stores an overridden `title`
* WITHOUT the flag, and that title is still the one routing depends on.
*/
function keepPostTypeBuiltIns(existing: DataTable, next: DataField[]): DataField[] {
if (existing.kind !== 'postType') return next
const supplied = new Set(next.map((field) => field.id))
const current = new Map(existing.fields.map((field) => [field.id, field]))
const canonical = new Map(buildPostTypeDefaultFields().map((field) => [field.id, field]))

const held: DataField[] = []
for (const id of POST_TYPE_MANDATORY_FIELD_IDS) {
if (supplied.has(id)) continue
// Prefer the stored definition; fall back to the canonical default when
// the field is absent altogether. That second case REPAIRS a table an
// earlier patch already stripped — a post type with no `slug` field is
// never a state anyone chose, so the next patch puts it back rather than
// faithfully preserving the damage.
const field = current.get(id) ?? canonical.get(id)
if (field) held.push(field)
}
return held.length === 0 ? next : [...held, ...next]
}

export async function createDataTable(
db: DbClient,
input: CreateDataTableInput,
Expand Down Expand Up @@ -247,7 +293,12 @@ export async function updateDataTable(
tableId: string,
input: UpdateDataTableInput,
): Promise<DataTable | null> {
const fields = input.fields === undefined ? null : normalizeDataTableFields(input.fields)
let fields: DataField[] | null = null
if (input.fields !== undefined) {
const existing = await getDataTable(db, tableId)
if (!existing) return null
fields = keepPostTypeBuiltIns(existing, normalizeDataTableFields(input.fields))
}
const routeBase = input.routeBase === undefined ? null : normalizeRouteBase(input.routeBase)
const { rows } = await db<DataTableRow>`
update data_tables
Expand Down Expand Up @@ -280,7 +331,9 @@ export async function insertDataTableIfAbsent(
db: DbClient,
input: CreateDataTableInput,
): Promise<boolean> {
const fields = normalizeDataTableFields(input.fields ?? [])
// Same seeding as createDataTable: `merge-add` / `merge-overwrite` is an
// import, and an imported post type has to be routable too.
const fields = withPostTypeBuiltIns(input.kind, normalizeDataTableFields(input.fields ?? []))
const { rows } = await db<{ id: string }>`
insert into data_tables (
id,
Expand Down
23 changes: 23 additions & 0 deletions src/__tests__/server/dataCms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,29 @@ describe('data CMS repository', () => {
it('updates table identity, route, labels, and field settings', async () => {
const nextFields = defaultFields.slice(0, 2)
const db = makeDataFakeDb([
// updateDataTable reads the table first, so a patch cannot drop the
// mandatory `title`/`slug` of a post type by omitting them.
(sql) => {
if (!sql.startsWith('select id, name, slug, kind')) return undefined
return {
rows: [{
id: 'products',
name: 'Products',
slug: 'products',
kind: 'postType',
route_base: '/products',
singular_label: 'Product',
plural_label: 'Products',
primary_field_id: 'title',
fields_json: defaultFields,
created_by_user_id: null,
updated_by_user_id: null,
created_at: rowDate('2026-05-01T10:00:00Z'),
updated_at: rowDate('2026-05-01T10:00:00Z'),
}],
rowCount: 1,
}
},
(sql, params) => {
if (!sql.startsWith('update data_tables')) return undefined
expect(params).toContain('Catalog')
Expand Down
200 changes: 198 additions & 2 deletions src/__tests__/server/postTypeBuiltInFields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
* data API, an MCP connector, or an import used to arrive without them, and
* `slugForTable` returns an empty slug for a table with no `slug` field — so
* every entry in that post type was published with no public route.
*
* The same hole existed on UPDATE, where it is worse: `fields` is a
* whole-list replacement, so adding one custom field by PATCHing the custom
* field list silently deleted every built-in. The table saved, the rows kept
* their `slug` cell, and the next save of any row recomputed the routable
* slug as '' — turning live entry routes into redirects with no error
* anywhere.
*/
import { describe, expect, it } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
Expand All @@ -15,9 +22,13 @@ import { createSqliteClient } from '../../../server/db/sqlite'
import { runMigrations } from '../../../server/db/runMigrations'
import { sqliteMigrations } from '../../../server/db/migrations-sqlite'
import type { DbClient } from '../../../server/db/client'
import { createDataTable } from '../../../server/repositories/data'
import { createDataTable, getDataTable, updateDataTable } from '../../../server/repositories/data'
import { insertDataTableIfAbsent } from '../../../server/repositories/data/tables'
import { slugForTable } from '@core/data/cells'
import { POST_TYPE_MANDATORY_FIELD_IDS } from '@core/data/schemas'
import {
POST_TYPE_MANDATORY_FIELD_IDS,
POST_TYPE_OPTIONAL_BUILTIN_FIELD_IDS,
} from '@core/data/schemas'

async function setupDb(): Promise<{ db: DbClient; cleanup: () => Promise<void> }> {
const dir = await mkdtemp(join(tmpdir(), 'instatic-posttype-'))
Expand Down Expand Up @@ -91,6 +102,28 @@ describe('createDataTable — post-type built-in fields', () => {
}
})

it('seeds the built-in fields on the import path too', async () => {
const { db, cleanup } = await setupDb()
try {
const inserted = await insertDataTableIfAbsent(db, {
id: 'tbl-imported',
name: 'Recipes',
slug: 'recipes',
kind: 'postType',
routeBase: '/recipes',
singularLabel: 'Recipe',
pluralLabel: 'Recipes',
fields: [{ type: 'text', id: 'crop', label: 'Crop' }],
})
expect(inserted).toBe(true)
const table = await getDataTable(db, 'tbl-imported')
expect(table!.fields.map((field) => field.id)).toContain('slug')
expect(slugForTable(table!, { slug: 'tomato' })).toBe('tomato')
} finally {
await cleanup()
}
})

it('leaves ordinary data tables untouched', async () => {
const { db, cleanup } = await setupDb()
try {
Expand All @@ -110,3 +143,166 @@ describe('createDataTable — post-type built-in fields', () => {
}
})
})

describe('updateDataTable — post-type built-in fields survive a PATCH', () => {
/** Create the shape the bug needs: a post type with built-ins plus customs. */
async function seedRecipes(db: DbClient) {
return createDataTable(db, {
name: 'Recipes',
slug: 'recipes',
kind: 'postType',
routeBase: '/recipes',
singularLabel: 'Recipe',
pluralLabel: 'Recipes',
fields: [{ type: 'text', id: 'crop', label: 'Crop' }],
})
}

it('keeps the built-ins when a patch sends only the custom fields', async () => {
const { db, cleanup } = await setupDb()
try {
const table = await seedRecipes(db)
// Adding one custom field. This is the natural payload, and it used to
// delete every built-in, `slug` included.
const updated = await updateDataTable(db, table.id, {
fields: [
{ type: 'text', id: 'crop', label: 'Crop' },
{ type: 'text', id: 'yield', label: 'Yield' },
],
})

const ids = updated!.fields.map((field) => field.id)
for (const required of POST_TYPE_MANDATORY_FIELD_IDS) {
expect(ids).toContain(required)
}
expect(ids).toContain('crop')
expect(ids).toContain('yield')
} finally {
await cleanup()
}
})

it('leaves entries routable after such a patch', async () => {
const { db, cleanup } = await setupDb()
try {
const table = await seedRecipes(db)
const updated = await updateDataTable(db, table.id, {
fields: [{ type: 'text', id: 'crop', label: 'Crop' }],
})
// The actual damage: with no `slug` field this returns '', and every
// published entry in the post type loses its public route on next save.
expect(slugForTable(updated!, { title: 'Tomato', slug: 'tomato' })).toBe('tomato')
} finally {
await cleanup()
}
})

it('preserves a customised built-in rather than reseeding the default', async () => {
const { db, cleanup } = await setupDb()
try {
const table = await createDataTable(db, {
name: 'Recipes',
slug: 'recipes',
kind: 'postType',
singularLabel: 'Recipe',
pluralLabel: 'Recipes',
fields: [{ type: 'text', id: 'title', label: 'Recipe name' }],
})
const updated = await updateDataTable(db, table.id, {
fields: [{ type: 'text', id: 'crop', label: 'Crop' }],
})
const title = updated!.fields.filter((field) => field.id === 'title')
expect(title).toHaveLength(1)
expect(title[0]!.label).toBe('Recipe name')
} finally {
await cleanup()
}
})

it('still lets a patch relabel a built-in it does send', async () => {
const { db, cleanup } = await setupDb()
try {
const table = await seedRecipes(db)
const updated = await updateDataTable(db, table.id, {
fields: [
{ type: 'text', id: 'slug', label: 'Web address', required: true, builtIn: true },
{ type: 'text', id: 'crop', label: 'Crop' },
],
})
const slug = updated!.fields.filter((field) => field.id === 'slug')
expect(slug).toHaveLength(1)
expect(slug[0]!.label).toBe('Web address')
} finally {
await cleanup()
}
})

it('restores title/slug on a table an earlier patch already stripped', async () => {
const { db, cleanup } = await setupDb()
try {
const table = await seedRecipes(db)
// Simulate the damage this bug shipped: a table already missing them.
await db`update data_tables set fields_json = ${[{ type: 'text', id: 'crop', label: 'Crop' }]} where id = ${table.id}`
const damaged = await getDataTable(db, table.id)
expect(damaged!.fields.map((field) => field.id)).not.toContain('slug')

const repaired = await updateDataTable(db, table.id, {
fields: [{ type: 'text', id: 'crop', label: 'Crop' }],
})
for (const required of POST_TYPE_MANDATORY_FIELD_IDS) {
expect(repaired!.fields.map((field) => field.id)).toContain(required)
}
expect(slugForTable(repaired!, { slug: 'tomato' })).toBe('tomato')
} finally {
await cleanup()
}
})

it('lets a patch drop an OPTIONAL built-in, which the Data inspector offers back', async () => {
const { db, cleanup } = await setupDb()
try {
const table = await seedRecipes(db)
const updated = await updateDataTable(db, table.id, {
fields: [{ type: 'text', id: 'crop', label: 'Crop' }],
})
const ids = updated!.fields.map((field) => field.id)
for (const optional of POST_TYPE_OPTIONAL_BUILTIN_FIELD_IDS) {
expect(ids).not.toContain(optional)
}
} finally {
await cleanup()
}
})

it('lets a patch drop a CUSTOM field', async () => {
const { db, cleanup } = await setupDb()
try {
const table = await seedRecipes(db)
const updated = await updateDataTable(db, table.id, { fields: [] })
expect(updated!.fields.map((field) => field.id)).not.toContain('crop')
expect(updated!.fields.map((field) => field.id)).toContain('slug')
} finally {
await cleanup()
}
})

it('does not touch the fields of an ordinary data table', async () => {
const { db, cleanup } = await setupDb()
try {
const table = await createDataTable(db, {
name: 'Chambers',
slug: 'chambers',
kind: 'data',
singularLabel: 'Chamber',
pluralLabel: 'Chambers',
fields: [{ type: 'text', id: 'chamberCode', label: 'Chamber' }],
})
const updated = await updateDataTable(db, table.id, {
fields: [{ type: 'text', id: 'rack', label: 'Rack' }],
})
expect(updated!.fields.map((field) => field.id)).toEqual(['rack'])
} finally {
await cleanup()
}
})
})
Loading