diff --git a/src/main/data/db/backup/contributor-types.ts b/src/main/data/db/backup/contributor-types.ts index b6e47f246f..9e283df9a4 100644 --- a/src/main/data/db/backup/contributor-types.ts +++ b/src/main/data/db/backup/contributor-types.ts @@ -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 ────────────────────────────────── diff --git a/src/main/data/services/__tests__/backupContributor-agents.test.ts b/src/main/data/services/__tests__/backupContributor-agents.test.ts new file mode 100644 index 0000000000..8ce47bd803 --- /dev/null +++ b/src/main/data/services/__tests__/backupContributor-agents.test.ts @@ -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() + }) +}) diff --git a/src/main/data/services/__tests__/backupContributor-miniapps.test.ts b/src/main/data/services/__tests__/backupContributor-miniapps.test.ts new file mode 100644 index 0000000000..d37254eb03 --- /dev/null +++ b/src/main/data/services/__tests__/backupContributor-miniapps.test.ts @@ -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() + }) +}) diff --git a/src/main/data/services/__tests__/backupContributor-paintings.test.ts b/src/main/data/services/__tests__/backupContributor-paintings.test.ts new file mode 100644 index 0000000000..d70f09e532 --- /dev/null +++ b/src/main/data/services/__tests__/backupContributor-paintings.test.ts @@ -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() + }) +}) diff --git a/src/main/data/services/__tests__/backupContributor-providers.test.ts b/src/main/data/services/__tests__/backupContributor-providers.test.ts new file mode 100644 index 0000000000..83f7284b1b --- /dev/null +++ b/src/main/data/services/__tests__/backupContributor-providers.test.ts @@ -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() + }) +}) diff --git a/src/main/data/services/__tests__/backupContributor-topics.test.ts b/src/main/data/services/__tests__/backupContributor-topics.test.ts new file mode 100644 index 0000000000..c54bf1d6bb --- /dev/null +++ b/src/main/data/services/__tests__/backupContributor-topics.test.ts @@ -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([['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() // 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() + }) +}) diff --git a/src/main/data/services/backupContributor-agents.ts b/src/main/data/services/backupContributor-agents.ts new file mode 100644 index 0000000000..3fff166b0d --- /dev/null +++ b/src/main/data/services/backupContributor-agents.ts @@ -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({ + 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 +}) diff --git a/src/main/data/services/backupContributor-miniapps.ts b/src/main/data/services/backupContributor-miniapps.ts new file mode 100644 index 0000000000..f10d549a22 --- /dev/null +++ b/src/main/data/services/backupContributor-miniapps.ts @@ -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({ + 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 +}) diff --git a/src/main/data/services/backupContributor-paintings.ts b/src/main/data/services/backupContributor-paintings.ts new file mode 100644 index 0000000000..5e44e9902d --- /dev/null +++ b/src/main/data/services/backupContributor-paintings.ts @@ -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({ + 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 +}) diff --git a/src/main/data/services/backupContributor-providers.ts b/src/main/data/services/backupContributor-providers.ts new file mode 100644 index 0000000000..745edba23b --- /dev/null +++ b/src/main/data/services/backupContributor-providers.ts @@ -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({ + 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 +}) diff --git a/src/main/data/services/backupContributor-topics.ts b/src/main/data/services/backupContributor-topics.ts new file mode 100644 index 0000000000..652b663ca2 --- /dev/null +++ b/src/main/data/services/backupContributor-topics.ts @@ -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({ + 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 + } + } + } + } +})