Skip to content
Open
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
11 changes: 9 additions & 2 deletions docs/user-defined-types/design.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ import {
defineProperty,
defineSameTxProcessor,
} from '@/data/api'
import {addedTypes, hasBlockType, propertyNameProp} from '@/data/properties'
import {addedTypes, aliasesProp, hasBlockType, propertyNameProp} from '@/data/properties'
import {PAGE_TYPE, PROPERTY_SCHEMA_TYPE} from '@/data/blockTypes'
import {propertySchemasFacet, typesFacet} from '@/data/facets'
import {createChild} from '@/data/internals/kernelMutators'
import {createChild} from '@/data/mutators'

// ──────────────────────────────────────────────────────────────────────
// Phase 1 API addition: Repo.onTypesChange
Expand Down Expand Up @@ -531,6 +531,13 @@ export async function createTypeBlock(
await repo.addTypeInTx(tx, newId, PAGE_TYPE, {}, typeSnapshot)
await tx.setProperty(newId, blockTypeLabelProp, trimmedLabel)
await tx.setProperty(newId, blockTypePropertiesProp, args.propertySchemaIds)
// The type doubles as its `[[label]]` page: claim the label as an
// alias so references resolve to THIS block (§3, block-id = type-id:
// the id `[[Person]]` resolves to). The label is therefore
// workspace-unique and this tx rejects (`alias.collision`) on a
// duplicate; rename parity is self-maintaining afterwards via the
// alias-sync same-tx processor (content → alias).
await tx.setProperty(newId, aliasesProp, [trimmedLabel])
}, {scope: ChangeScope.BlockDefault, description: `createTypeBlock ${trimmedLabel}`})

await waitForTypeRegistrationBounded(
Expand Down
44 changes: 43 additions & 1 deletion src/data/typeExtraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createTestRepo } from '@/data/test/createTestRepo'
import { kernelPropertyUiExtension } from '@/components/propertyEditors/typesPropertyUi'
import { kernelValuePresetsExtension } from '@/components/propertyEditors/kernelValuePresets'
import {
aliasesProp,
blockTypeLabelProp,
blockTypePropertiesProp,
getBlockTypes,
Expand Down Expand Up @@ -131,6 +132,43 @@ describe('createTypeBlock', () => {
expect(row!.properties[blockTypePropertiesProp.name]).toEqual([schemaBlockId])
expect(row!.parentId).toBe(env.repo.typesPageId)
expect(row!.content).toBe('Task')
// The type doubles as its `[[Task]]` page — it claims the label as
// an alias.
expect(row!.properties[aliasesProp.name]).toEqual(['Task'])
})

it('claims the label as an alias so [[label]] resolves to the type block', async () => {
env = await setup()
const typeId = await createTypeBlock(env.repo, {
workspaceId: WS,
label: 'Task',
propertySchemaIds: [],
})

// The alias index is trigger-maintained; `[[Task]]` resolution
// (aliasLookup) must land on the type-definition block rather than
// minting a separate alias-seat page.
const resolved = await env.repo.query
.aliasLookup({workspaceId: WS, alias: 'Task'})
.load()
expect(resolved?.id).toBe(typeId)
})

it('rejects when the label collides with an existing page alias', async () => {
env = await setup()
// A prior `[[Task]]` reference (or create-page UI) already left a
// live block claiming the alias in this workspace.
const pageId = await env.repo.mutate.createChild({parentId: env.repo.typesPageId!})
await env.repo.tx(async tx => {
await tx.update(pageId, {content: 'Task'})
await tx.setProperty(pageId, aliasesProp, ['Task'])
}, {scope: ChangeScope.BlockDefault})

await expect(createTypeBlock(env.repo, {
workspaceId: WS,
label: 'Task',
propertySchemaIds: [],
})).rejects.toMatchObject({code: 'alias.collision'})
})

it('returns a typeId that is registered in repo.types by the time the promise resolves', async () => {
Expand All @@ -146,8 +184,12 @@ describe('createTypeBlock', () => {

it('returns distinct ids on repeat calls (no in-place collision)', async () => {
env = await setup()
// Distinct labels: each type claims its label as a workspace-unique
// alias, so two same-named types can't coexist (covered separately).
// The property under test is that repeat calls mint fresh block ids
// rather than reusing a deterministic one.
const a = await createTypeBlock(env.repo, {workspaceId: WS, label: 'Task', propertySchemaIds: []})
const b = await createTypeBlock(env.repo, {workspaceId: WS, label: 'Task', propertySchemaIds: []})
const b = await createTypeBlock(env.repo, {workspaceId: WS, label: 'Project', propertySchemaIds: []})
expect(a).not.toBe(b)
expect(env.repo.types.has(a)).toBe(true)
expect(env.repo.types.has(b)).toBe(true)
Expand Down
19 changes: 18 additions & 1 deletion src/data/typeExtraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
PROPERTY_SCHEMA_TYPE,
} from '@/data/blockTypes'
import {
aliasesProp,
blockTypeLabelProp,
blockTypePropertiesProp,
hasBlockType,
Expand Down Expand Up @@ -102,7 +103,10 @@ export interface CreateTypeBlockArgs {
/** Create a fresh `block-type` block on the workspace's Types page.
* Returns the new block id (== type id once registered). The
* returned id is in the live `repo.types` registry by the time the
* promise resolves. */
* promise resolves. The block also claims its label as an `alias`, so
* it doubles as the `[[label]]` page — meaning the label is
* workspace-unique and this rejects (`alias.collision`) when a live
* block already claims it. */
export async function createTypeBlock(
repo: Repo,
args: CreateTypeBlockArgs,
Expand Down Expand Up @@ -213,6 +217,19 @@ export async function createTypeBlock(
await repo.addTypeInTx(tx, newId, PAGE_TYPE, {}, typeSnapshot)
await tx.setProperty(newId, blockTypeLabelProp, trimmedLabel)
await tx.setProperty(newId, blockTypePropertiesProp, args.propertySchemaIds)
// A defined type doubles as its `[[label]]` page: claim the label as
// an alias so references resolve to THIS block instead of minting a
// duplicate alias-seat page (design.html — the type id is the same id
// `[[Person]]` references resolve to). Parity is self-maintaining
// afterwards: `writeBlockTypeLabel` keeps `content` in lockstep with
// the label, and `aliasSyncProcessor` reconciles `content → alias` on
// rename, so the alias tracks the label without extra wiring here.
//
// A type's name is therefore workspace-unique: the
// `block_aliases_workspace_alias_unique` trigger rejects this tx (as a
// structured `alias.collision`) if a live block already claims the
// label — a duplicate-named type is ambiguous for `[[label]]` anyway.
await tx.setProperty(newId, aliasesProp, [trimmedLabel])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Backfill aliases for existing type blocks

When a workspace already has user-defined types created before this change, only new createTypeBlock calls execute this write, so those existing block-type rows never claim their labels. aliasSyncProcessor skips rows with an empty alias list and writeBlockTypeLabel only changes label/content, so renaming an old type still won't seed an alias; after upgrade, [[Task]] can still miss the existing type and mint a duplicate alias-seat page unless a workspace backfill/migration seeds these aliases or handles collisions.

Useful? React with 👍 / 👎.

}, {scope: ChangeScope.BlockDefault, description: `createTypeBlock ${trimmedLabel}`})

await waitForTypeRegistrationBounded(
Expand Down
Loading