feat(backup): add 5 core domain contributors

5 core domains per architecture §四/§五: PROVIDERS (user_provider+user_model,
renamable:false), MINIAPPS (mini_app natural-key appId), PAINTINGS (painting+
painting_file_ref), TOPICS (topic+message+chat_message_file_ref, renamable),
AGENTS (4 roots + agent_channel_task/agent_skill/agent_mcp_server junctions +
job_schedule row-scope).

contributor-types: FileMergePolicy.strategy += 'remote-fills-local-empty'
(PROVIDERS credential-aware merge — seeded []/skeleton treated as missing).

48 domain unit tests pass; tsgo clean. Domain tests are standalone (do not
import finalize/registry) so this PR bases on contributors, ahead of manager.

Signed-off-by: George·Dong <98630204+GeorgeDong32@users.noreply.github.com>
This commit is contained in:
George·Dong
2026-07-03 12:10:04 +08:00
parent 85e1d8a2ee
commit c726ce8aec
11 changed files with 1175 additions and 1 deletions

View File

@@ -150,7 +150,7 @@ export interface UniqueMergeRule {
export interface FieldMergePolicy {
readonly table: DbTableName
readonly column: DbColumnName
readonly strategy: 'remote-fills-local-null' | 'deep-merge' | 'local-priority'
readonly strategy: 'remote-fills-local-null' | 'remote-fills-local-empty' | 'deep-merge' | 'local-priority'
}
// ─── Contributor schema + policy + operations ──────────────────────────────────

View File

@@ -0,0 +1,199 @@
// Unit tests for the AGENTS contributor — pure declaration assertions (no DB).
import { table } from '@main/data/db/backup/dbSchemaRefs'
import { describe, expect, it } from 'vitest'
import { AGENTS_CONTRIBUTOR } from '../backupContributor-agents'
describe('AGENTS contributor', () => {
it('owns the 9 agent tables (8 graph tables + job_schedule row-scope)', () => {
expect(AGENTS_CONTRIBUTOR.schema.tables).toEqual([
table('agent'),
table('agent_session'),
table('agent_session_message'),
table('agent_workspace'),
table('agent_channel'),
table('agent_channel_task'),
table('agent_skill'),
table('agent_mcp_server'),
table('job_schedule')
])
})
it('declares the job_schedule(type=agent.task) row-scope', () => {
expect(AGENTS_CONTRIBUTOR.schema.rowScopes).toEqual([
expect.objectContaining({
table: table('job_schedule'),
ownerDomain: 'AGENTS',
filter: expect.objectContaining({ column: 'type', op: 'eq', value: 'agent.task' })
})
])
})
it('declares every FK as the correct ReferenceKind', () => {
const refs = AGENTS_CONTRIBUTOR.schema.references
const find = (t: string, c: string) => refs.find((r) => r.table === t && r.column === c)
// ── agent_session ──
// agentId → agent: optional (onDelete set null)
expect(find('agent_session', 'agentId')).toEqual(
expect.objectContaining({ referencedDomain: 'AGENTS', kind: 'optional' })
)
// workspaceId → agent_workspace: cross-aggregate OWNING (onDelete cascade), NOT a member
expect(find('agent_session', 'workspaceId')).toEqual(
expect.objectContaining({ referencedDomain: 'AGENTS', kind: 'owning' })
)
// ── agent_session_message ──
// sessionId → agent_session: owning (cascade) — drives aggregate membership
expect(find('agent_session_message', 'sessionId')).toEqual(
expect.objectContaining({ referencedDomain: 'AGENTS', kind: 'owning' })
)
// modelId → user_model: optional (set null)
expect(find('agent_session_message', 'modelId')).toEqual(
expect.objectContaining({ referencedDomain: 'PROVIDERS', kind: 'optional' })
)
// ── agent_channel ──
expect(find('agent_channel', 'agentId')).toEqual(
expect.objectContaining({ referencedDomain: 'AGENTS', kind: 'optional' })
)
expect(find('agent_channel', 'sessionId')).toEqual(
expect.objectContaining({ referencedDomain: 'AGENTS', kind: 'optional' })
)
// ── agent_channel_task (junction: dual cascade) ──
expect(find('agent_channel_task', 'channelId')).toEqual(
expect.objectContaining({ referencedDomain: 'AGENTS', kind: 'junction' })
)
expect(find('agent_channel_task', 'taskId')).toEqual(
expect.objectContaining({ referencedDomain: 'AGENTS', kind: 'junction' })
)
// ── agent_skill (junction: dual cascade) ──
expect(find('agent_skill', 'agentId')).toEqual(
expect.objectContaining({ referencedDomain: 'AGENTS', kind: 'junction' })
)
expect(find('agent_skill', 'skillId')).toEqual(
expect.objectContaining({ referencedDomain: 'SKILLS', kind: 'junction' })
)
// ── agent_mcp_server (junction: dual cascade) ──
expect(find('agent_mcp_server', 'agentId')).toEqual(
expect.objectContaining({ referencedDomain: 'AGENTS', kind: 'junction' })
)
expect(find('agent_mcp_server', 'mcpServerId')).toEqual(
expect.objectContaining({ referencedDomain: 'MCP_SERVERS', kind: 'junction' })
)
// ── agent (scalar model refs → PROVIDERS, optional) ──
expect(find('agent', 'model')).toEqual(
expect.objectContaining({ referencedDomain: 'PROVIDERS', kind: 'optional' })
)
expect(find('agent', 'planModel')).toEqual(
expect.objectContaining({ referencedDomain: 'PROVIDERS', kind: 'optional' })
)
expect(find('agent', 'smallModel')).toEqual(
expect.objectContaining({ referencedDomain: 'PROVIDERS', kind: 'optional' })
)
})
it('agent_session aggregate has agent_session_message as sessionId include member, non-renamable', () => {
const session = AGENTS_CONTRIBUTOR.schema.aggregates.find((a) => a.root === table('agent_session'))
expect(session).toBeDefined()
expect(session!.identityKey).toEqual(['id'])
expect(session!.renamable).toBe(false)
expect(session!.members).toEqual([
expect.objectContaining({ table: table('agent_session_message'), viaColumn: 'sessionId', cascade: 'include' })
])
})
it('agent_session.members does NOT include agent_workspace (cross-aggregate owning ref, §5.4)', () => {
const session = AGENTS_CONTRIBUTOR.schema.aggregates.find((a) => a.root === table('agent_session'))
expect(session!.members?.some((m) => m.table === table('agent_workspace'))).toBe(false)
})
it('agent_workspace is a natural-key aggregate keyed by path UNIQUE, non-renamable', () => {
const workspace = AGENTS_CONTRIBUTOR.schema.aggregates.find((a) => a.root === table('agent_workspace'))
expect(workspace).toBeDefined()
expect(workspace!.identityKey).toEqual(['path'])
expect(workspace!.renamable).toBe(false)
expect(workspace!.members ?? []).toEqual([])
})
it('agent_channel and agent are single-table non-renamable aggregates', () => {
const channel = AGENTS_CONTRIBUTOR.schema.aggregates.find((a) => a.root === table('agent_channel'))
expect(channel!.identityKey).toEqual(['id'])
expect(channel!.renamable).toBe(false)
expect(channel!.members ?? []).toEqual([])
const agent = AGENTS_CONTRIBUTOR.schema.aggregates.find((a) => a.root === table('agent'))
expect(agent!.identityKey).toEqual(['id'])
expect(agent!.renamable).toBe(false)
expect(agent!.members ?? []).toEqual([])
})
it('job_schedule(agent.task) is a natural-key aggregate keyed by (type,name), non-renamable', () => {
const schedule = AGENTS_CONTRIBUTOR.schema.aggregates.find((a) => a.root === table('job_schedule'))
expect(schedule).toBeDefined()
expect(schedule!.identityKey).toEqual(['type', 'name'])
expect(schedule!.renamable).toBe(false)
expect(schedule!.members ?? []).toEqual([])
})
it('all aggregates are non-renamable (no cloneAggregate required, #16)', () => {
for (const aggregate of AGENTS_CONTRIBUTOR.schema.aggregates) {
expect(aggregate.renamable).toBe(false)
}
expect(AGENTS_CONTRIBUTOR.operations).toBeUndefined()
})
it('declares the required + tolerant JSON soft references', () => {
const jsonRefs = AGENTS_CONTRIBUTOR.schema.jsonSoftReferences
// agent_session_message.data: tolerant file-ref (attachments)
expect(jsonRefs).toContainEqual(
expect.objectContaining({
table: table('agent_session_message'),
column: 'data',
target: 'file-ref',
ownerDomain: 'AGENTS',
kind: 'tolerant'
})
)
// agent_channel.workspace: required entity-id (AgentSessionWorkspaceSource.workspaceId)
expect(jsonRefs).toContainEqual(
expect.objectContaining({
table: table('agent_channel'),
column: 'workspace',
target: 'entity-id',
ownerDomain: 'AGENTS',
kind: 'required'
})
)
// job_schedule.jobInputTemplate: required entity-id (same workspace source)
expect(jsonRefs).toContainEqual(
expect.objectContaining({
table: table('job_schedule'),
column: 'jobInputTemplate',
target: 'entity-id',
ownerDomain: 'AGENTS',
kind: 'required'
})
)
})
it('declares no fileRefSourcePolicies (no AGENTS-owned FileRefSourceType)', () => {
expect(AGENTS_CONTRIBUTOR.schema.fileRefSourcePolicies).toEqual([])
})
it('primary keys are non-ambiguous', () => {
for (const pk of AGENTS_CONTRIBUTOR.schema.primaryKeys) {
expect(pk.ambiguous).toBeFalsy()
}
})
it('schema is deep-frozen (mutation throws)', () => {
expect(() => {
;(AGENTS_CONTRIBUTOR.schema.tables as unknown as string[]).push('x')
}).toThrow()
})
})

View File

@@ -0,0 +1,43 @@
// Unit tests for the MINIAPPS contributor — pure declaration assertions (no DB).
import { table } from '@main/data/db/backup/dbSchemaRefs'
import { describe, expect, it } from 'vitest'
import { MINIAPPS_CONTRIBUTOR } from '../backupContributor-miniapps'
describe('MINIAPPS contributor', () => {
it('owns mini_app', () => {
expect(MINIAPPS_CONTRIBUTOR.schema.tables).toEqual([table('mini_app')])
})
it('declares no references (presetMiniAppId points at a non-DB preset, not a DB row)', () => {
expect(MINIAPPS_CONTRIBUTOR.schema.references).toEqual([])
})
it('mini_app aggregate is a single-table natural-key root, non-renamable', () => {
const aggregate = MINIAPPS_CONTRIBUTOR.schema.aggregates[0]
expect(aggregate.root).toBe(table('mini_app'))
expect(aggregate.identityKey).toEqual(['appId'])
expect(aggregate.renamable).toBe(false)
expect(aggregate.members).toEqual([])
})
it('declares no fileRefSourcePolicies', () => {
expect(MINIAPPS_CONTRIBUTOR.schema.fileRefSourcePolicies).toEqual([])
})
it('declares no jsonSoftReferences', () => {
expect(MINIAPPS_CONTRIBUTOR.schema.jsonSoftReferences).toEqual([])
})
it('primary key is non-ambiguous (mini_app appId natural)', () => {
for (const pk of MINIAPPS_CONTRIBUTOR.schema.primaryKeys) {
expect(pk.ambiguous).toBeFalsy()
}
})
it('schema is deep-frozen (mutation throws)', () => {
expect(() => {
;(MINIAPPS_CONTRIBUTOR.schema.tables as unknown as string[]).push('x')
}).toThrow()
})
})

View File

@@ -0,0 +1,80 @@
// Unit tests for the PAINTINGS contributor — pure declaration assertions (no DB).
import { table } from '@main/data/db/backup/dbSchemaRefs'
import { describe, expect, it } from 'vitest'
import { PAINTINGS_CONTRIBUTOR } from '../backupContributor-paintings'
describe('PAINTINGS contributor', () => {
it('owns painting + painting_file_ref', () => {
expect(PAINTINGS_CONTRIBUTOR.schema.tables).toEqual([table('painting'), table('painting_file_ref')])
})
it('declares sourceId owning + fileEntryId cross-domain junction reference', () => {
const refs = PAINTINGS_CONTRIBUTOR.schema.references
expect(refs).toHaveLength(2)
// painting_file_ref.sourceId → painting: same-domain owning (aggregate membership).
expect(refs).toContainEqual(
expect.objectContaining({
table: table('painting_file_ref'),
column: 'sourceId',
referencedDomain: 'PAINTINGS',
kind: 'owning'
})
)
// painting_file_ref.fileEntryId → file_entry (FILE_STORAGE): junction (dual-cascade
// junction table, cross-domain endpoint — mirrors TOPICS chat_message_file_ref).
expect(refs).toContainEqual(
expect.objectContaining({
table: table('painting_file_ref'),
column: 'fileEntryId',
referencedDomain: 'FILE_STORAGE',
kind: 'junction'
})
)
})
it('painting aggregate has painting_file_ref as a sourceId include member, non-renamable', () => {
const aggregate = PAINTINGS_CONTRIBUTOR.schema.aggregates[0]
expect(aggregate.root).toBe(table('painting'))
expect(aggregate.identityKey).toEqual(['id'])
expect(aggregate.renamable).toBe(false)
expect(aggregate.members).toEqual([
expect.objectContaining({ table: table('painting_file_ref'), viaColumn: 'sourceId', cascade: 'include' })
])
})
it('identityKey is the single-column root PK (uniqueness via uuid-v4)', () => {
const aggregate = PAINTINGS_CONTRIBUTOR.schema.aggregates[0]
expect(aggregate.identityKey).toEqual(['id'])
expect(aggregate.identityKey).toHaveLength(1)
})
it('classifies painting sourceType in fileRefSourcePolicies', () => {
const policies = PAINTINGS_CONTRIBUTOR.schema.fileRefSourcePolicies
expect(policies).toHaveLength(1)
expect(policies).toContainEqual(
expect.objectContaining({
sourceType: 'painting',
ownerDomain: 'PAINTINGS',
resourcePolicy: 'include-with-owner',
sourceTable: table('painting_file_ref')
})
)
})
it('declares no jsonSoftReferences', () => {
expect(PAINTINGS_CONTRIBUTOR.schema.jsonSoftReferences).toEqual([])
})
it('primary keys are non-ambiguous (painting uuid-v4; painting_file_ref uuid-v4)', () => {
for (const pk of PAINTINGS_CONTRIBUTOR.schema.primaryKeys) {
expect(pk.ambiguous).toBeFalsy()
}
})
it('schema is deep-frozen (mutation throws)', () => {
expect(() => {
;(PAINTINGS_CONTRIBUTOR.schema.tables as unknown as string[]).push('x')
}).toThrow()
})
})

View File

@@ -0,0 +1,73 @@
// Unit tests for the PROVIDERS contributor — pure declaration assertions (no DB).
import { table } from '@main/data/db/backup/dbSchemaRefs'
import { describe, expect, it } from 'vitest'
import { PROVIDERS_CONTRIBUTOR } from '../backupContributor-providers'
describe('PROVIDERS contributor', () => {
it('owns user_provider + user_model', () => {
expect(PROVIDERS_CONTRIBUTOR.schema.tables).toEqual([table('user_provider'), table('user_model')])
})
it('declares providerId owning reference (same-domain, aggregate membership)', () => {
const refs = PROVIDERS_CONTRIBUTOR.schema.references
expect(refs).toHaveLength(1)
// user_model.providerId → user_provider: same-domain owning (cascade).
expect(refs).toContainEqual(
expect.objectContaining({
table: table('user_model'),
column: 'providerId',
referencedDomain: 'PROVIDERS',
kind: 'owning'
})
)
})
it('user_provider aggregate has user_model as a providerId include member, non-renamable', () => {
const aggregate = PROVIDERS_CONTRIBUTOR.schema.aggregates[0]
expect(aggregate.root).toBe(table('user_provider'))
expect(aggregate.identityKey).toEqual(['providerId'])
expect(aggregate.renamable).toBe(false)
expect(aggregate.members).toEqual([
expect.objectContaining({ table: table('user_model'), viaColumn: 'providerId', cascade: 'include' })
])
})
it('identity key is unique (user_provider.providerId is the business identity)', () => {
const aggregate = PROVIDERS_CONTRIBUTOR.schema.aggregates[0]
expect(aggregate.identityKey).toEqual(['providerId'])
})
it('declares no fileRefSourcePolicies and no jsonSoftReferences', () => {
expect(PROVIDERS_CONTRIBUTOR.schema.fileRefSourcePolicies).toEqual([])
expect(PROVIDERS_CONTRIBUTOR.schema.jsonSoftReferences).toEqual([])
})
it('primary keys are non-ambiguous (user_provider/user_model natural-key)', () => {
for (const pk of PROVIDERS_CONTRIBUTOR.schema.primaryKeys) {
expect(pk.ambiguous).toBeFalsy()
}
})
it('declares uniqueMergeRules for user_model by [providerId, modelId]', () => {
expect(PROVIDERS_CONTRIBUTOR.backupPolicy.uniqueMergeRules).toEqual([
expect.objectContaining({ table: table('user_model'), uniqueColumns: ['providerId', 'modelId'] })
])
})
it('declares fieldMergePolicies for apiKeys + authConfig (remote-fills-local-empty)', () => {
// remote-fills-local-empty treats [], null, and empty/skeleton auth as missing —
// seeded providers ship apiKeys=[] / auth skeletons, so plain -local-null would
// drop backed-up credentials.
expect(PROVIDERS_CONTRIBUTOR.backupPolicy.fieldMergePolicies).toEqual([
expect.objectContaining({ table: table('user_provider'), column: 'apiKeys', strategy: 'remote-fills-local-empty' }),
expect.objectContaining({ table: table('user_provider'), column: 'authConfig', strategy: 'remote-fills-local-empty' })
])
})
it('schema is deep-frozen (mutation throws)', () => {
expect(() => {
;(PROVIDERS_CONTRIBUTOR.schema.tables as unknown as string[]).push('x')
}).toThrow()
})
})

View File

@@ -0,0 +1,185 @@
// Unit tests for the TOPICS contributor — pure declaration assertions (no DB).
import type { CloneAggregateContext } from '@main/data/db/backup/contexts'
import { table } from '@main/data/db/backup/dbSchemaRefs'
import { describe, expect, it } from 'vitest'
import { TOPICS_CONTRIBUTOR } from '../backupContributor-topics'
describe('TOPICS contributor', () => {
it('owns topic + message + chat_message_file_ref', () => {
expect(TOPICS_CONTRIBUTOR.schema.tables).toEqual([table('topic'), table('message'), table('chat_message_file_ref')])
})
it('declares 7 references: topicId/parentId owning + sourceId owning + modelId/assistantId/groupId optional + fileEntryId junction', () => {
const refs = TOPICS_CONTRIBUTOR.schema.references
expect(refs).toHaveLength(7)
// message.topicId → topic: same-domain owning, drives aggregate membership.
expect(refs).toContainEqual(
expect.objectContaining({
table: table('message'),
column: 'topicId',
referencedDomain: 'TOPICS',
kind: 'owning'
})
)
// message.parentId → message: self-FK owning (cascade), excluded from members.
expect(refs).toContainEqual(
expect.objectContaining({
table: table('message'),
column: 'parentId',
referencedDomain: 'TOPICS',
kind: 'owning'
})
)
// message.modelId → user_model (PROVIDERS): optional.
expect(refs).toContainEqual(
expect.objectContaining({
table: table('message'),
column: 'modelId',
referencedDomain: 'PROVIDERS',
kind: 'optional'
})
)
// chat_message_file_ref.sourceId → message: same-domain owning (nested member).
expect(refs).toContainEqual(
expect.objectContaining({
table: table('chat_message_file_ref'),
column: 'sourceId',
referencedDomain: 'TOPICS',
kind: 'owning'
})
)
// chat_message_file_ref.fileEntryId → file_entry: cross-domain junction.
expect(refs).toContainEqual(
expect.objectContaining({
table: table('chat_message_file_ref'),
column: 'fileEntryId',
referencedDomain: 'FILE_STORAGE',
kind: 'junction'
})
)
// topic.assistantId → assistant (ASSISTANTS): optional.
expect(refs).toContainEqual(
expect.objectContaining({
table: table('topic'),
column: 'assistantId',
referencedDomain: 'ASSISTANTS',
kind: 'optional'
})
)
// topic.groupId → group (TAGS_GROUPS): optional.
expect(refs).toContainEqual(
expect.objectContaining({
table: table('topic'),
column: 'groupId',
referencedDomain: 'TAGS_GROUPS',
kind: 'optional'
})
)
})
it('topic aggregate is renamable with message(topicId) + chat_message_file_ref(sourceId→message) include members', () => {
const aggregate = TOPICS_CONTRIBUTOR.schema.aggregates[0]
expect(aggregate.root).toBe(table('topic'))
expect(aggregate.identityKey).toEqual(['id'])
expect(aggregate.renamable).toBe(true)
expect(aggregate.members).toEqual([
expect.objectContaining({ table: table('message'), viaColumn: 'topicId', cascade: 'include' }),
// Nested member: parent=message disambiguates the sourceId→message leg.
expect.objectContaining({
table: table('chat_message_file_ref'),
viaColumn: 'sourceId',
parent: table('message'),
cascade: 'include'
})
])
})
it('identity key is the single-column topic PK (uniqueness for cross-device match)', () => {
const aggregate = TOPICS_CONTRIBUTOR.schema.aggregates[0]
expect(aggregate.identityKey).toEqual(['id'])
// Single-column root PK satisfies finalize #26 (renamable root PK is single).
expect(aggregate.identityKey).toHaveLength(1)
})
it('declares chat_message FileRefSourceType owned by TOPICS (include-with-owner)', () => {
const policies = TOPICS_CONTRIBUTOR.schema.fileRefSourcePolicies
expect(policies).toHaveLength(1)
expect(policies[0]).toEqual(
expect.objectContaining({
sourceType: 'chat_message',
ownerDomain: 'TOPICS',
resourcePolicy: 'include-with-owner',
sourceTable: table('message')
})
)
})
it('declares message.data as a tolerant file-ref JSON soft reference', () => {
const softRefs = TOPICS_CONTRIBUTOR.schema.jsonSoftReferences
expect(softRefs).toHaveLength(1)
expect(softRefs[0]).toEqual(
expect.objectContaining({
table: table('message'),
column: 'data',
target: 'file-ref',
ownerDomain: 'TOPICS',
kind: 'tolerant'
})
)
})
it('renamable aggregate supplies cloneAggregate (finalize #16)', () => {
expect(TOPICS_CONTRIBUTOR.operations?.cloneAggregate).toBeDefined()
})
it('cloneAggregate replaces the root PK and rewrites activeNodeId to the cloned message id (§5.3)', async () => {
const cloneAggregate = TOPICS_CONTRIBUTOR.operations!.cloneAggregate!
// cloneAggregate is pure (no db on the context). The importer provides
// memberKeyMap; here we stub the message old→new id mapping.
const messageMap = new Map<string, string>([['msg-old', 'msg-new']])
const memberKeyMap = new Map([[table('message'), messageMap]] as const)
const ctx = {
aggregate: { root: table('topic') },
registry: { getPrimaryKey: () => ({ columns: ['id'] }) },
rootRow: { id: 'topic-old', name: 't', activeNodeId: 'msg-old' },
newRootKey: 'topic-new',
memberKeyMap
} as unknown as CloneAggregateContext
const result = await cloneAggregate(ctx)
expect(result.rootRow.id).toBe('topic-new')
expect(result.rootRow.name).toBe('t') // non-PK fields preserved by the spread
expect(result.rootRow.activeNodeId).toBe('msg-new') // rewritten to cloned message id
})
it('cloneAggregate clears activeNodeId when the referenced message is absent from backup', async () => {
const cloneAggregate = TOPICS_CONTRIBUTOR.operations!.cloneAggregate!
const messageMap = new Map<string, string>() // empty: message not cloned
const memberKeyMap = new Map([[table('message'), messageMap]] as const)
const ctx = {
aggregate: { root: table('topic') },
registry: { getPrimaryKey: () => ({ columns: ['id'] }) },
rootRow: { id: 'topic-old', activeNodeId: 'msg-gone' },
newRootKey: 'topic-new',
memberKeyMap
} as unknown as CloneAggregateContext
const result = await cloneAggregate(ctx)
expect(result.rootRow.activeNodeId).toBeNull() // cleared instead of dangling
})
it('primary keys are non-ambiguous (topic uuid-v4; message uuid-v7; chat_message_file_ref uuid-v4)', () => {
for (const pk of TOPICS_CONTRIBUTOR.schema.primaryKeys) {
expect(pk.ambiguous).toBeFalsy()
}
const topic = TOPICS_CONTRIBUTOR.schema.primaryKeys.find((p) => p.table === 'topic')!
expect(topic.kind).toBe('uuid-v4')
const message = TOPICS_CONTRIBUTOR.schema.primaryKeys.find((p) => p.table === 'message')!
expect(message.kind).toBe('uuid-v7')
})
it('schema is deep-frozen (mutation throws)', () => {
expect(() => {
;(TOPICS_CONTRIBUTOR.schema.tables as unknown as string[]).push('x')
}).toThrow()
})
})

View File

@@ -0,0 +1,255 @@
// AGENTS backup contributor — owns the agent session/workspace/channel graph.
//
// Co-located in the agent owning module (the agent/session/channel tables are
// authored from src/main/ai + this flat data-services dir) per backup-architecture
// §7 placement. This is the most structurally complex domain (architecture §5):
// four independent aggregate roots + two junction tables + a shared-table row-scope.
//
// Aggregate boundaries (§3.5 / §5):
// - agent_session (+ agent_session_message via sessionId): uuid-entity, SKIP.
// renamable:false — agent_session.workspaceId is a cross-aggregate owning FK to
// the independent agent_workspace aggregate (§5.4); RENAME would clone a session
// while its workspace target merges under a different id → owning FK dangles.
// - agent_workspace: natural-key (path UNIQUE), FIELD_MERGE. renamable:false.
// Independent aggregate root — NOT a member of agent_session (§5.4: workspaceId
// is a cross-aggregate owning ref, excluded from members by #14).
// - agent_channel: uuid-entity, SKIP. renamable:false (single-table).
// - agent: uuid-entity, SKIP. renamable:false (single-table).
//
// Junction tables (not aggregate members, §5.2 — cascade-prune, not clone-inherited):
// - agent_channel_task: composite PK, dual cascade FK (channelId→agent_channel +
// taskId→job_schedule). agent_channel_task.taskId points at job_schedule rows
// owned by the row-scope below; identity propagation rewrites taskId when the
// target schedule row merges under a new canonical id (§5.4).
// - agent_skill: composite PK, dual cascade FK (agentId→agent +
// skillId→agent_global_skill → SKILLS domain).
// - agent_mcp_server: composite PK, dual cascade FK (agentId→agent +
// mcpServerId→mcp_server → MCP_SERVERS domain).
//
// Shared-table row-scope (§5 / invariant #5/#23):
// - job_schedule.type='agent.task' → AGENTS. job_schedule is a SHARED table (other
// types belong to other domains / runtime); AGENTS owns only the
// type='agent.task' row partition via rowScopes. Those rows are a natural-key
// aggregate keyed by (type,name) UNIQUE → FIELD_MERGE (agent task definitions,
// otherwise design-loss of user tasks).
//
// JSON soft refs (§6.1):
// - agent_session_message.data: tolerant fileEntryId attachment refs (same shape as
// message.data in TOPICS).
// - agent_channel.workspace.workspaceId + job_schedule(type='agent.task')
// .jobInputTemplate.workspace.workspaceId (AgentSessionWorkspaceSource): REQUIRED
// — target merge (agent_workspace FIELD_MERGE) must rewrite the embedded
// workspaceId via identity propagation, or the channel/scheduled-task silently
// references a dangling workspace (§5.4).
//
// Preset: full + lite (agent history/config is a core migrate scenario).
import type { BackupContributor } from '@main/data/db/backup/contributor-types'
import { column, columns, mirrorPk, table } from '@main/data/db/backup/dbSchemaRefs'
import { deepFreeze } from '@main/data/db/backup/freeze'
/**
* AGENTS domain. Four independent aggregate roots + two junction tables + one
* shared-table row-scope. conflictDefault derives per root: uuid-entity roots →
* SKIP; agent_workspace + job_schedule(agent.task) → natural-key → FIELD_MERGE
* (§6.2). All aggregates are renamable:false.
*/
export const AGENTS_CONTRIBUTOR = deepFreeze<BackupContributor>({
domain: 'AGENTS',
schema: {
tables: [
table('agent'),
table('agent_session'),
table('agent_session_message'),
table('agent_workspace'),
table('agent_channel'),
table('agent_channel_task'),
table('agent_skill'),
table('agent_mcp_server'),
// job_schedule: AGENTS owns the type='agent.task' row partition. Listed as a
// table so #12/#13 recognize its jsonSoftRef + aggregate; rowScopes below filter
// export/restore to only agent.task rows (other job_schedule types are runtime).
table('job_schedule')
],
references: [
// ── agent_session ──────────────────────────────────────────────────────
// agent_session.agentId → agent (AGENTS): optional (onDelete set null). #25-required.
{ table: table('agent_session'), column: column('agentId'), referencedDomain: 'AGENTS', kind: 'optional' },
// agent_session.workspaceId → agent_workspace (AGENTS): cross-aggregate OWNING
// (onDelete cascade). Declared owning per #19/#25, but NOT a member of the
// session aggregate (#14: target is a different aggregate root, not session.root).
// See §5.4 — workspaceId is a cascade NOT NULL owning FK to an independent root.
{
table: table('agent_session'),
column: column('workspaceId'),
referencedDomain: 'AGENTS',
kind: 'owning'
},
// ── agent_session_message ──────────────────────────────────────────────
// agent_session_message.sessionId → agent_session: same-domain owning (cascade).
// Drives aggregate membership (#14/#15) — include member viaColumn.
{
table: table('agent_session_message'),
column: column('sessionId'),
referencedDomain: 'AGENTS',
kind: 'owning'
},
// agent_session_message.modelId → user_model (PROVIDERS): optional (onDelete set null). #25-required.
{
table: table('agent_session_message'),
column: column('modelId'),
referencedDomain: 'PROVIDERS',
kind: 'optional'
},
// ── agent_channel ──────────────────────────────────────────────────────
// agent_channel.agentId → agent (AGENTS): optional (onDelete set null). #25-required.
{ table: table('agent_channel'), column: column('agentId'), referencedDomain: 'AGENTS', kind: 'optional' },
// agent_channel.sessionId → agent_session (AGENTS): optional (onDelete set null). #25-required.
{ table: table('agent_channel'), column: column('sessionId'), referencedDomain: 'AGENTS', kind: 'optional' },
// ── agent_channel_task (junction: dual cascade) ────────────────────────
// channelId → agent_channel: same-domain junction (cascade). #25-required.
{
table: table('agent_channel_task'),
column: column('channelId'),
referencedDomain: 'AGENTS',
kind: 'junction'
},
// taskId → job_schedule (AGENTS row-scope): same-domain junction (cascade).
// Target is the job_schedule(type='agent.task') row partition owned below.
// #25-required; identity propagation rewrites taskId on target FIELD_MERGE (§5.4).
{
table: table('agent_channel_task'),
column: column('taskId'),
referencedDomain: 'AGENTS',
kind: 'junction'
},
// ── agent_skill (junction: dual cascade) ───────────────────────────────
// agentId → agent: same-domain junction (cascade). #25-required.
{ table: table('agent_skill'), column: column('agentId'), referencedDomain: 'AGENTS', kind: 'junction' },
// skillId → agent_global_skill (SKILLS): cross-domain junction (cascade). #25-required.
{ table: table('agent_skill'), column: column('skillId'), referencedDomain: 'SKILLS', kind: 'junction' },
// ── agent_mcp_server (junction: dual cascade) ──────────────────────────
// agentId → agent: same-domain junction (cascade). #25-required.
{ table: table('agent_mcp_server'), column: column('agentId'), referencedDomain: 'AGENTS', kind: 'junction' },
// mcpServerId → mcp_server (MCP_SERVERS): cross-domain junction (cascade). #25-required.
{
table: table('agent_mcp_server'),
column: column('mcpServerId'),
referencedDomain: 'MCP_SERVERS',
kind: 'junction'
},
// ── agent (scalar model refs) ──────────────────────────────────────────
// agent.model / planModel / smallModel → user_model (PROVIDERS): optional
// (onDelete set null). Three #25-required FKs.
{ table: table('agent'), column: column('model'), referencedDomain: 'PROVIDERS', kind: 'optional' },
{ table: table('agent'), column: column('planModel'), referencedDomain: 'PROVIDERS', kind: 'optional' },
{ table: table('agent'), column: column('smallModel'), referencedDomain: 'PROVIDERS', kind: 'optional' }
],
primaryKeys: [
mirrorPk('agent'),
mirrorPk('agent_session'),
mirrorPk('agent_session_message'),
mirrorPk('agent_workspace'),
mirrorPk('agent_channel'),
mirrorPk('agent_channel_task'),
mirrorPk('agent_skill'),
mirrorPk('agent_mcp_server'),
mirrorPk('job_schedule')
],
aggregates: [
// agent_session + agent_session_message: uuid-entity, SKIP, non-renamable.
{
root: table('agent_session'),
identityKey: columns(['id']),
members: [
{ table: table('agent_session_message'), viaColumn: column('sessionId'), cascade: 'include' }
],
renamable: false
},
// agent_workspace: natural-key (path UNIQUE), FIELD_MERGE, non-renamable.
// identityKey includes the UNIQUE non-PK column `path` (§6.2 unique-backing).
// identityClass explicit — root PK `id` is uuid, so the finalize default
// (uuid-entity→SKIP) would mis-derive; `path` is the business identity (§5.4).
{
root: table('agent_workspace'),
identityKey: columns(['path']),
identityClass: 'natural-key',
renamable: false
},
// agent_channel: single-table uuid-entity, SKIP, non-renamable.
{
root: table('agent_channel'),
identityKey: columns(['id']),
renamable: false
},
// agent: single-table uuid-entity, SKIP, non-renamable.
{
root: table('agent'),
identityKey: columns(['id']),
renamable: false
},
// job_schedule(type='agent.task') row-scope: natural-key ((type,name) UNIQUE),
// FIELD_MERGE. Root is the shared job_schedule table; ownership is row-scoped
// (only type='agent.task' rows belong to AGENTS). non-renamable.
// identityClass explicit — root PK `id` is uuid, finalize default would
// mis-derive uuid-entity→SKIP; (type,name) is the business identity (§5.4).
{
root: table('job_schedule'),
identityKey: columns(['type', 'name']),
identityClass: 'natural-key',
renamable: false
}
],
// Shared-table row partition: job_schedule.type='agent.task' → AGENTS.
// Other job_schedule types are not owned here (invariant #5/#23 row-scope coverage).
rowScopes: [
{ table: table('job_schedule'), ownerDomain: 'AGENTS', filter: { column: column('type'), op: 'eq', value: 'agent.task' } }
],
fileRefSourcePolicies: [],
jsonSoftReferences: [
// agent_session_message.data embeds attachment fileEntryId soft refs (tolerant —
// missing blob degrades to a toast + orphan check, no identity propagation, §5.4).
{
table: table('agent_session_message'),
column: column('data'),
target: 'file-ref',
ownerDomain: 'AGENTS',
kind: 'tolerant'
},
// agent_channel.workspace embeds an AgentSessionWorkspaceSource whose workspaceId
// points at an agent_workspace (natural-key target). REQUIRED — target FIELD_MERGE
// must rewrite the embedded workspaceId via identity propagation (§5.4), or the
// channel silently references a dangling/merged-away workspace.
{
table: table('agent_channel'),
column: column('workspace'),
target: 'entity-id',
ownerDomain: 'AGENTS',
kind: 'required'
},
// job_schedule(type='agent.task').jobInputTemplate embeds the same
// AgentSessionWorkspaceSource.workspaceId. REQUIRED for the same reason — a
// scheduled agent task whose workspace id was merged away would fire against a
// dangling workspace. Covers the shared job_schedule table's agent.task rows.
{
table: table('job_schedule'),
column: column('jobInputTemplate'),
target: 'entity-id',
ownerDomain: 'AGENTS',
kind: 'required'
}
]
},
backupPolicy: {},
// All aggregates are renamable:false → cloneAggregate is NOT required (#16).
// TODO(C/D track): afterImport must re-arm job_schedule(type='agent.task') timers
// (DB import does not call registerJobSchedule — agent tasks would not fire until
// restart, §5). Not a finalize concern; wired with the C/D restore track.
operations: undefined
})

View File

@@ -0,0 +1,42 @@
// MINIAPPS backup contributor — owns the `mini_app` table.
//
// Co-located in the miniapps owning module (MiniAppService / MiniAppSeeder live in
// this flat data-services dir) per backup-architecture §7 placement. Single-table
// schema-only domain: no cross-domain FKs (presetMiniAppId points at an app-builtin
// preset, NOT a DB row — §5.4 scalar-ID three-way rule: non-DB resource → not an
// EntityReference candidate), no aggregate members, no file-ref / JSON soft refs,
// no operations hooks (see openspec simple-domains.md "MINIAPPS").
//
// Preset: full + lite (lite includes miniapps — small config rows).
import type { BackupContributor } from '@main/data/db/backup/contributor-types'
import { columns, mirrorPk, table } from '@main/data/db/backup/dbSchemaRefs'
import { deepFreeze } from '@main/data/db/backup/freeze'
/**
* MINIAPPS domain: user miniapp configurations keyed by the natural `appId` PK.
* Per §6.2 identityClass derives to natural-key (PK kind natural), conflictDefault
* to FIELD_MERGE (aligns the same app across devices). renamable:false — a natural
* single-column PK clone would collide on the same appId, RENAME degrades to SKIP.
*/
export const MINIAPPS_CONTRIBUTOR = deepFreeze<BackupContributor>({
domain: 'MINIAPPS',
schema: {
tables: [table('mini_app')],
references: [],
primaryKeys: [mirrorPk('mini_app')],
aggregates: [
{
root: table('mini_app'),
identityKey: columns(['appId']),
members: [],
renamable: false
}
],
fileRefSourcePolicies: [],
jsonSoftReferences: []
},
backupPolicy: {},
// Schema-only: no file resources, no row transform, no restoreResources.
operations: undefined
})

View File

@@ -0,0 +1,71 @@
// PAINTINGS backup contributor — owns `painting` + `painting_file_ref`.
//
// Co-located in the paintings owning module (PaintingService lives in this flat
// data-services dir) per backup-architecture §7 placement. painting is the
// aggregate root; painting_file_ref is an include member via sourceId
// (onDelete cascade). painting_file_ref is a junction with a second cascade FK
// fileEntryId→file_entry (FILE_STORAGE) — a cross-domain owning reference, NOT a
// member of the painting aggregate (§5.1: post-#16532 painting_file_ref belongs to
// PAINTINGS by source domain; FILE_STORAGE owns file_entry only).
//
// painting has NO FKs (providerId/modelId are scalar soft refs pointing at DB rows
// without a declared FK — per §5.4 three-way scalar-ID rule, these are NOT
// EntityReferences; they stay tolerant: missing target degrades, no rewrite).
//
// renamable:false — paintings are uuid-entity with no business UNIQUE key; RENAME
// is not applicable (architecture §3.5).
//
// Preset: full only (lite-excluded — painting images are large file blobs).
import type { BackupContributor } from '@main/data/db/backup/contributor-types'
import { column, columns, mirrorPk, table } from '@main/data/db/backup/dbSchemaRefs'
import { deepFreeze } from '@main/data/db/backup/freeze'
/**
* PAINTINGS domain. painting (uuid-v4) is the aggregate root; painting_file_ref
* (uuid-v4) is an include member via sourceId. painting_file_ref.fileEntryId→
* file_entry is a cross-domain owning reference (cascade). conflictDefault
* derives to SKIP (uuid-entity → SKIP, §6.2).
*/
export const PAINTINGS_CONTRIBUTOR = deepFreeze<BackupContributor>({
domain: 'PAINTINGS',
schema: {
tables: [table('painting'), table('painting_file_ref')],
references: [
// painting_file_ref.sourceId → painting: same-domain owning (cascade). Drives
// the aggregate membership (#14/#15) and is #25-required.
{ table: table('painting_file_ref'), column: column('sourceId'), referencedDomain: 'PAINTINGS', kind: 'owning' },
// painting_file_ref.fileEntryId → file_entry (FILE_STORAGE): junction (dual-cascade
// junction table — sourceId & fileEntryId both onDelete cascade). Cross-domain
// endpoint, cascade-prune not DELETE_ROW; mirrors TOPICS chat_message_file_ref.fileEntryId
// per §5.2. #25-required (declare so the FK is covered; finalize #19 verifies cascade).
{
table: table('painting_file_ref'),
column: column('fileEntryId'),
referencedDomain: 'FILE_STORAGE',
kind: 'junction'
}
],
primaryKeys: [mirrorPk('painting'), mirrorPk('painting_file_ref')],
aggregates: [
{
root: table('painting'),
identityKey: columns(['id']),
members: [{ table: table('painting_file_ref'), viaColumn: column('sourceId'), cascade: 'include' }],
renamable: false
}
],
// file_ref.sourceType='painting' → ownerDomain=PAINTINGS (finalize #11). Drives
// export-time file blob collection via painting_file_ref (§5.1).
fileRefSourcePolicies: [
{ sourceType: 'painting', ownerDomain: 'PAINTINGS', resourcePolicy: 'include-with-owner', sourceTable: table('painting_file_ref') }
],
jsonSoftReferences: []
},
backupPolicy: {},
// TODO(C/D track) — collectFileResources (export painting file blobs via
// painting_file_ref.fileEntryId, filter deletedAt IS NULL) + restoreResources
// (blob restore runs before DB import, returns skippedFileEntryIds). Not a
// finalize concern; wired with the C/D restore track (like FILE_STORAGE / KNOWLEDGE).
operations: undefined
})

View File

@@ -0,0 +1,81 @@
// PROVIDERS backup contributor — owns `user_provider` + `user_model`.
//
// Co-located in the providers owning module (ProviderService / ModelService live
// in this flat data-services dir) per backup-architecture §7 placement.
// user_model is an include member of the user_provider aggregate via `providerId`
// (onDelete cascade). The domain is self-contained: no cross-domain FKs.
//
// natural-key identity: user_provider.providerId is the business identity (a stable
// provider key like 'openai'/'anthropic'), so cross-device merges line up without
// UUID collisions. conflictDefault derives to FIELD_MERGE (natural-key →
// FIELD_MERGE, §6.2) — column-level merge keeps the local API key and fills in
// fields only present remotely, preventing API key loss on restore.
//
// renamable:false — user_model.id is a derived key (NOT a stable cross-device
// identity; two devices generate different ids for the same provider+model pair).
// The real model identity is the UNIQUE(providerId, modelId) pair. A RENAME clone
// would have to re-derive model ids and rewrite every cross-domain FK that points
// at them (message.modelId, assistant.modelId, knowledge_base.embeddingModelId,
// etc.) — no safe clone path, so RENAME degrades to SKIP (architecture §3.5/§5).
//
// Preset: full + lite (configuration domain — users need their model service
// config + API keys on a new machine).
import type { BackupContributor } from '@main/data/db/backup/contributor-types'
import { column, columns, mirrorPk, table } from '@main/data/db/backup/dbSchemaRefs'
import { deepFreeze } from '@main/data/db/backup/freeze'
/**
* PROVIDERS domain. user_provider (natural-key providerId) is the aggregate root;
* user_model (natural-key id, UNIQUE [providerId, modelId]) is an include member
* via providerId. conflictDefault derives to FIELD_MERGE (natural-key →
* FIELD_MERGE, §6.2). fieldMergePolicies keep local API keys and fill remote-only
* credential columns; uniqueMergeRules merge models by the business unique pair.
*/
export const PROVIDERS_CONTRIBUTOR = deepFreeze<BackupContributor>({
domain: 'PROVIDERS',
schema: {
tables: [table('user_provider'), table('user_model')],
references: [
// user_model.providerId → user_provider.providerId: same-domain owning
// (cascade). Drives aggregate membership (#14/#15) and is #25-required.
{ table: table('user_model'), column: column('providerId'), referencedDomain: 'PROVIDERS', kind: 'owning' }
],
primaryKeys: [mirrorPk('user_provider'), mirrorPk('user_model')],
aggregates: [
{
root: table('user_provider'),
identityKey: columns(['providerId']),
members: [{ table: table('user_model'), viaColumn: column('providerId'), cascade: 'include' }],
renamable: false
}
],
fileRefSourcePolicies: [],
jsonSoftReferences: []
},
backupPolicy: {
// Merge user_model by its non-PK business UNIQUE pair so a provider's models
// line up across devices without colliding on the derived `id` PK.
uniqueMergeRules: [{ table: table('user_model'), uniqueColumns: columns(['providerId', 'modelId']) }],
// Credential columns: keep the local value and only fill from remote when local
// is null/empty/default-skeleton. Seeded providers ship apiKeys=[] and non-null
// authConfig skeletons, so a plain `remote-fills-local-null` would treat those as
// "present" and silently drop backed-up credentials. `remote-fills-local-empty`
// treats [], null, and empty/skeleton auth configs as missing — preserves a
// working local API key, brings in keys present only in the backup (§5 "防丢 API
// key"). Restore (C/D track) implements the empty/skeleton detection.
fieldMergePolicies: [
{
table: table('user_provider'),
column: column('apiKeys'),
strategy: 'remote-fills-local-empty'
},
{
table: table('user_provider'),
column: column('authConfig'),
strategy: 'remote-fills-local-empty'
}
]
},
operations: undefined
})

View File

@@ -0,0 +1,145 @@
// TOPICS backup contributor — owns `topic` + `message` + `chat_message_file_ref`.
//
// Co-located in the topics owning module (TopicService / MessageService, the table
// CRUD owners, live in this flat data-services dir) per backup-architecture §7
// placement. The topic aggregate is renamable: RENAME on id conflict clones the
// topic AND its message tree (and chat_message_file_ref rows), and cloneAggregate
// must additionally rewrite the `topic.activeNodeId` scalar soft ref to the cloned
// message's new id (architecture §5.3 — required, not optional).
//
// Post-#16532 the old polymorphic `file_ref` was split; `chat_message_file_ref`
// (FK sourceId→message cascade + fileEntryId→file_entry cascade) belongs to TOPICS
// by source domain. It is an include member (via sourceId→message), not a junction
// reference, because its TOPICS-side leg (sourceId) remaps with the message tree on
// clone; the FILE_STORAGE leg (fileEntryId) is declared as a junction reference.
//
// message.data carries a tolerant fileEntryId JSON soft ref (attachments). The
// `chat_message` FileRefSourceType is owned here (include-with-owner) so file blobs
// follow chat messages in full backups.
//
// Preset: full + lite (chat history is a core migrate scenario).
import type { BackupContributor } from '@main/data/db/backup/contributor-types'
import { column, columns, mirrorPk, table } from '@main/data/db/backup/dbSchemaRefs'
import { deepFreeze } from '@main/data/db/backup/freeze'
/**
* TOPICS domain. topic (uuid-v4) is the aggregate root; message (uuid-v7) is an
* include member via topicId (onDelete cascade); chat_message_file_ref (uuid-v4) is
* a nested include member via sourceId→message (onDelete cascade). conflictDefault
* derives to SKIP (uuid-entity → SKIP, §6.2).
*
* message.parentId is a self-FK (message→message, cascade). It is declared owning
* to satisfy #19 (cascade→owning) and #25 (every FK declared), but does NOT enter
* the aggregate members: it targets `message` (the member table), not the root
* `topic`, so #14 excludes it from member derivation.
*
* topic.activeNodeId is a scalar text soft ref to a message id with NO FK. It is
* not an EntityReference (#24 requires a FK); instead cloneAggregate rewrites it to
* the cloned message's new id (§5.3) on RENAME.
*/
export const TOPICS_CONTRIBUTOR = deepFreeze<BackupContributor>({
domain: 'TOPICS',
schema: {
tables: [table('topic'), table('message'), table('chat_message_file_ref')],
references: [
// message.topicId → topic: same-domain owning (cascade). Drives aggregate
// membership (#14/#15) and is #25-required.
{ table: table('message'), column: column('topicId'), referencedDomain: 'TOPICS', kind: 'owning' },
// message.parentId → message: self-FK (cascade). Declared owning per #19/#25,
// but excluded from members (#14: targets the member, not the root).
{ table: table('message'), column: column('parentId'), referencedDomain: 'TOPICS', kind: 'owning' },
// message.modelId → user_model (PROVIDERS): optional (onDelete set null). #25-required.
{ table: table('message'), column: column('modelId'), referencedDomain: 'PROVIDERS', kind: 'optional' },
// chat_message_file_ref.sourceId → message: same-domain owning (cascade), nested
// include member (file refs follow their owning message on clone/prune).
{
table: table('chat_message_file_ref'),
column: column('sourceId'),
referencedDomain: 'TOPICS',
kind: 'owning'
},
// chat_message_file_ref.fileEntryId → file_entry (FILE_STORAGE): cross-domain
// junction (cascade-prune with FILE_STORAGE).
{
table: table('chat_message_file_ref'),
column: column('fileEntryId'),
referencedDomain: 'FILE_STORAGE',
kind: 'junction'
},
// topic.assistantId → assistant (ASSISTANTS): optional (onDelete set null). #25-required.
{ table: table('topic'), column: column('assistantId'), referencedDomain: 'ASSISTANTS', kind: 'optional' },
// topic.groupId → group (TAGS_GROUPS): optional (onDelete set null). #25-required.
{ table: table('topic'), column: column('groupId'), referencedDomain: 'TAGS_GROUPS', kind: 'optional' }
],
primaryKeys: [mirrorPk('topic'), mirrorPk('message'), mirrorPk('chat_message_file_ref')],
aggregates: [
{
root: table('topic'),
identityKey: columns(['id']),
members: [
{ table: table('message'), viaColumn: column('topicId'), cascade: 'include' },
{
table: table('chat_message_file_ref'),
viaColumn: column('sourceId'),
// Nested member: chat_message_file_ref.sourceId points at message (the
// parent member), not the root topic — declare parent to disambiguate
// (#14/#15 multi-owning-ref rule).
parent: table('message'),
cascade: 'include'
}
],
renamable: true
}
],
fileRefSourcePolicies: [
// chat_message file refs are owned by TOPICS (source domain) and bundled with
// the topic/message tree in full backups (#11 coverage).
{
sourceType: 'chat_message',
ownerDomain: 'TOPICS',
resourcePolicy: 'include-with-owner',
sourceTable: table('message')
}
],
jsonSoftReferences: [
// message.data embeds attachment fileEntryId soft refs (tolerant — missing blob
// degrades to a toast + orphan check, no identity propagation, §5.4).
{
table: table('message'),
column: column('data'),
target: 'file-ref',
ownerDomain: 'TOPICS',
kind: 'tolerant'
}
]
},
backupPolicy: {},
operations: {
// Renamable aggregate (RENAME on conflict) → cloneAggregate is required (#16).
// Pure: no db on the context. The root PK column is read from the registry
// (#26 guarantees a single-column root PK). The importer remaps member rows
// (message.id, chat_message_file_ref.sourceId/fileEntryId) via memberKeyMap.
// Additionally, topic.activeNodeId is a scalar soft ref to a message id with no
// FK (§5.3): on RENAME it MUST be rewritten to the cloned message's new id, or
// the restored topic points at the old aggregate's node / dangles.
cloneAggregate: async (ctx) => {
const pkColumn = ctx.registry.getPrimaryKey(ctx.aggregate.root).columns[0]
// Map old message id → cloned message id from the importer's memberKeyMap, and
// rewrite activeNodeId so the renamed topic points inside its own clone.
const messageKeyMap = ctx.memberKeyMap.get(table('message'))
const oldActiveNodeId = ctx.rootRow.activeNodeId
const newActiveNodeId =
typeof oldActiveNodeId === 'string' && messageKeyMap ? messageKeyMap.get(oldActiveNodeId) : undefined
return {
rootRow: {
...ctx.rootRow,
[pkColumn]: ctx.newRootKey,
// Rewrite the scalar soft ref; if activeNodeId is null or its message was
// not cloned (e.g. absent from backup), clear it rather than dangle.
activeNodeId: newActiveNodeId ?? null
}
}
}
}
})