feat(data-api): add group and pin resources with polymorphic pin table

Two sibling resources land together because their migration SQL is
generated as one unit and their API-schema / handler wiring has to go
in the same commit to keep the repo compiling.

group: upgrade the existing group table from `sort_order INT` to
`order_key TEXT` (`scopedOrderKeyIndex('group', 'entityType')`) and
expose it as a first-class resource under /groups. Each entityType
owns an independent orderKey sequence. Reorder delegates to the new
applyScopedMoves helper.

pin: brand-new polymorphic table `(id, entityType, entityId, orderKey,
timestamps)` with UNIQUE(entityType, entityId) enforcing idempotency
at the DB layer. One table serves arbitrarily many consumers — same
precedent as entity_tag. No FK to consumer tables; every consumer
service's delete path must call `pinService.purgeForEntity(tx,
entityType, entityId)` to keep the two tables in sync (signature is
tx-first, the mainstream ORM convention; tagService.removeEntityTags
is the project's historical tx-last outlier and is left as-is).

PinService.pin is idempotent and concurrent-safe: a fast-path SELECT
returns existing rows, and a UNIQUE collision under concurrent INSERT
is caught, classified, and re-SELECTed so the caller never sees a
constraint error.

Handler layer is a thin Zod-parse shell — all scope inference, row
lookup, and orderKey computation live in the services.
This commit is contained in:
fullex
2026-04-21 23:28:14 -07:00
parent ce5fd66d10
commit 12e4e60005
19 changed files with 4734 additions and 10 deletions

View File

@@ -0,0 +1,15 @@
CREATE TABLE `pin` (
`id` text PRIMARY KEY NOT NULL,
`entity_type` text NOT NULL,
`entity_id` text NOT NULL,
`order_key` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `pin_entity_type_entity_id_unique_idx` ON `pin` (`entity_type`,`entity_id`);--> statement-breakpoint
CREATE INDEX `pin_entity_type_order_key_idx` ON `pin` (`entity_type`,`order_key`);--> statement-breakpoint
DROP INDEX `group_entity_sort_idx`;--> statement-breakpoint
ALTER TABLE `group` ADD `order_key` text NOT NULL;--> statement-breakpoint
CREATE INDEX `group_entity_type_order_key_idx` ON `group` (`entity_type`,`order_key`);--> statement-breakpoint
ALTER TABLE `group` DROP COLUMN `sort_order`;

File diff suppressed because it is too large Load Diff

View File

@@ -98,6 +98,13 @@
"when": 1776744800060,
"tag": "0013_fantastic_joystick",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1776838196467,
"tag": "0014_hot_ultron",
"breakpoints": true
}
],
"version": "7"

View File

@@ -0,0 +1,97 @@
/**
* Group API Schema definitions
*
* Contains endpoints for Group CRUD and scoped reorder operations.
* Entity schemas and types live in `@shared/data/types/group`.
*/
import * as z from 'zod'
import { EntityTypeSchema } from '../../types/entityType'
import { type Group, GroupIdSchema as SharedGroupIdSchema, GroupNameSchema } from '../../types/group'
import type { OrderEndpoints } from './_endpointHelpers'
export const GroupIdSchema = SharedGroupIdSchema
// ============================================================================
// DTOs
// ============================================================================
/**
* DTO for creating a new group.
* `entityType` is locked at creation time (read-only afterwards).
*/
export const CreateGroupDtoSchema = z.object({
entityType: EntityTypeSchema,
name: GroupNameSchema
})
export type CreateGroupDto = z.infer<typeof CreateGroupDtoSchema>
/**
* DTO for updating an existing group. Only `name` is mutable.
*/
export const UpdateGroupDtoSchema = z.object({
name: GroupNameSchema.optional()
})
export type UpdateGroupDto = z.infer<typeof UpdateGroupDtoSchema>
/**
* Query params for `GET /groups`. `entityType` is required — listing across
* entity types has no business use case.
*/
export const ListGroupsQuerySchema = z.object({
entityType: EntityTypeSchema
})
export type ListGroupsQuery = z.infer<typeof ListGroupsQuerySchema>
// ============================================================================
// API Schema Definitions
// ============================================================================
/**
* Group API Schema definitions
*/
export type GroupSchemas = {
/**
* Groups collection endpoint
* @example GET /groups?entityType=topic
* @example POST /groups { "entityType": "topic", "name": "Research" }
*/
'/groups': {
/** List groups within a given entityType, ordered by orderKey */
GET: {
query: ListGroupsQuery
response: Group[]
}
/** Create a new group; appended to the end of its entityType bucket */
POST: {
body: CreateGroupDto
response: Group
}
}
/**
* Individual group endpoint
* @example GET /groups/abc123
* @example PATCH /groups/abc123 { "name": "Renamed" }
* @example DELETE /groups/abc123
*/
'/groups/:id': {
/** Get a group by ID */
GET: {
params: { id: string }
response: Group
}
/** Update a group's mutable fields */
PATCH: {
params: { id: string }
body: UpdateGroupDto
response: Group
}
/** Delete a group */
DELETE: {
params: { id: string }
response: void
}
}
} & OrderEndpoints<'/groups'>

View File

@@ -22,11 +22,13 @@
import type { AssertValidSchemas } from '../apiTypes'
import type { AssistantSchemas } from './assistants'
import type { FileProcessingSchemas } from './fileProcessing'
import type { GroupSchemas } from './groups'
import type { KnowledgeSchemas } from './knowledges'
import type { MCPServerSchemas } from './mcpServers'
import type { MessageSchemas } from './messages'
import type { MiniappSchemas } from './miniapps'
import type { ModelSchemas } from './models'
import type { PinSchemas } from './pins'
import type { ProviderSchemas } from './providers'
import type { TagSchemas } from './tags'
import type { TemporaryChatSchemas } from './temporaryChats'
@@ -58,5 +60,7 @@ export type ApiSchemas = AssertValidSchemas<
KnowledgeSchemas &
MiniappSchemas &
AssistantSchemas &
TagSchemas
TagSchemas &
GroupSchemas &
PinSchemas
>

View File

@@ -0,0 +1,86 @@
/**
* Pin API Schema definitions
*
* Contains endpoints for Pin CRUD and scoped reorder operations.
* Entity schemas and types live in `@shared/data/types/pin`.
*
* Note: there is no PATCH on `/pins/:id` — pins have no mutable business
* fields. `entityType` / `entityId` are immutable after creation, timestamps
* are auto, and `id` is auto.
*/
import * as z from 'zod'
import { EntityTypeSchema } from '../../types/entityType'
import { type Pin, PinIdSchema as SharedPinIdSchema } from '../../types/pin'
import type { OrderEndpoints } from './_endpointHelpers'
export const PinIdSchema = SharedPinIdSchema
// ============================================================================
// DTOs
// ============================================================================
/**
* DTO for creating (or re-using) a pin. Idempotent: when a pin already exists
* for the same (entityType, entityId) the service returns the existing row.
*/
export const CreatePinDtoSchema = z.object({
entityType: EntityTypeSchema,
entityId: z.uuidv4()
})
export type CreatePinDto = z.infer<typeof CreatePinDtoSchema>
/**
* Query params for `GET /pins`. `entityType` is required — listing across
* entity types has no business use case.
*/
export const ListPinsQuerySchema = z.object({
entityType: EntityTypeSchema
})
export type ListPinsQuery = z.infer<typeof ListPinsQuerySchema>
// ============================================================================
// API Schema Definitions
// ============================================================================
/**
* Pin API Schema definitions
*/
export type PinSchemas = {
/**
* Pins collection endpoint
* @example GET /pins?entityType=topic
* @example POST /pins { "entityType": "topic", "entityId": "..." }
*/
'/pins': {
/** List pins within a given entityType, ordered by orderKey */
GET: {
query: ListPinsQuery
response: Pin[]
}
/** Idempotent pin: returns the existing row when already pinned */
POST: {
body: CreatePinDto
response: Pin
}
}
/**
* Individual pin endpoint
* @example GET /pins/abc123
* @example DELETE /pins/abc123
*/
'/pins/:id': {
/** Get a pin by ID */
GET: {
params: { id: string }
response: Pin
}
/** Unpin (hard delete by pin id) */
DELETE: {
params: { id: string }
response: void
}
}
} & OrderEndpoints<'/pins'>

View File

@@ -0,0 +1,37 @@
/**
* Group entity types
*
* Groups are user-managed flat containers that organize entities (assistants,
* topics, sessions) under a chosen entityType. Ordering within an entityType is
* preserved via a fractional-indexing `orderKey`.
*/
import * as z from 'zod'
import { EntityTypeSchema } from './entityType'
// ============================================================================
// Group Entity
// ============================================================================
export const GroupIdSchema = z.uuidv4()
export const GroupNameSchema = z.string().trim().min(1).max(64)
/**
* Complete Group entity as returned by the API.
*/
export const GroupSchema = z.object({
/** Group ID (UUID v4, auto-generated by database) */
id: GroupIdSchema,
/** Entity type this group belongs to (tag / group / pin share the enum) */
entityType: EntityTypeSchema,
/** Display name of the group */
name: GroupNameSchema,
/** Fractional-indexing order key within this entityType */
orderKey: z.string().min(1),
/** Creation timestamp (ISO string) */
createdAt: z.iso.datetime(),
/** Last update timestamp (ISO string) */
updatedAt: z.iso.datetime()
})
export type Group = z.infer<typeof GroupSchema>

View File

@@ -0,0 +1,36 @@
/**
* Pin entity types
*
* Pins are a polymorphic non-destructive "promote to top" marker. Any entity
* type participating in the shared `EntityType` enum can be pinned by its id.
* Ordering is preserved per entityType via a fractional-indexing `orderKey`.
*/
import * as z from 'zod'
import { EntityTypeSchema } from './entityType'
// ============================================================================
// Pin Entity
// ============================================================================
export const PinIdSchema = z.uuidv4()
/**
* Complete Pin entity as returned by the API.
*/
export const PinSchema = z.object({
/** Pin ID (UUID v4, auto-generated by database) */
id: PinIdSchema,
/** Entity type this pin targets (tag / group / pin share the enum) */
entityType: EntityTypeSchema,
/** Target entity id (UUID v4) */
entityId: z.uuidv4(),
/** Fractional-indexing order key within this entityType */
orderKey: z.string().min(1),
/** Creation timestamp (ISO string) */
createdAt: z.iso.datetime(),
/** Last update timestamp (ISO string) */
updatedAt: z.iso.datetime()
})
export type Pin = z.infer<typeof PinSchema>

View File

@@ -0,0 +1,213 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { listByEntityTypeMock, createMock, getByIdMock, updateMock, deleteMock, reorderMock, reorderBatchMock } =
vi.hoisted(() => ({
listByEntityTypeMock: vi.fn(),
createMock: vi.fn(),
getByIdMock: vi.fn(),
updateMock: vi.fn(),
deleteMock: vi.fn(),
reorderMock: vi.fn(),
reorderBatchMock: vi.fn()
}))
vi.mock('@data/services/GroupService', () => ({
groupService: {
listByEntityType: listByEntityTypeMock,
create: createMock,
getById: getByIdMock,
update: updateMock,
delete: deleteMock,
reorder: reorderMock,
reorderBatch: reorderBatchMock
}
}))
import { groupHandlers } from '../groups'
const GROUP_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_GROUP_ID = '22222222-2222-4222-8222-222222222222'
describe('groupHandlers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('/groups GET', () => {
it('should delegate GET to groupService.listByEntityType', async () => {
listByEntityTypeMock.mockResolvedValueOnce([{ id: 'g1', entityType: 'topic', name: 'A' }])
const result = await groupHandlers['/groups'].GET({
query: { entityType: 'topic' }
} as never)
expect(listByEntityTypeMock).toHaveBeenCalledWith('topic')
expect(result).toEqual([{ id: 'g1', entityType: 'topic', name: 'A' }])
})
it('should reject a missing entityType query param with ZodError', async () => {
await expect(groupHandlers['/groups'].GET({ query: {} } as never)).rejects.toHaveProperty('name', 'ZodError')
expect(listByEntityTypeMock).not.toHaveBeenCalled()
})
it('should reject an unknown entityType query value with ZodError', async () => {
await expect(groupHandlers['/groups'].GET({ query: { entityType: 'invalid' } } as never)).rejects.toHaveProperty(
'name',
'ZodError'
)
expect(listByEntityTypeMock).not.toHaveBeenCalled()
})
})
describe('/groups POST', () => {
it('should parse POST body and call create', async () => {
createMock.mockResolvedValueOnce({ id: 'g1', entityType: 'topic', name: 'Research' })
await expect(
groupHandlers['/groups'].POST({
body: { entityType: 'topic', name: 'Research' }
} as never)
).resolves.toMatchObject({ id: 'g1' })
expect(createMock).toHaveBeenCalledWith({ entityType: 'topic', name: 'Research' })
})
it('should reject unknown entityType in POST body with ZodError', async () => {
await expect(
groupHandlers['/groups'].POST({
body: { entityType: 'bogus', name: 'Research' }
} as never)
).rejects.toHaveProperty('name', 'ZodError')
expect(createMock).not.toHaveBeenCalled()
})
it('should reject empty names in POST body with ZodError', async () => {
await expect(
groupHandlers['/groups'].POST({
body: { entityType: 'topic', name: '' }
} as never)
).rejects.toHaveProperty('name', 'ZodError')
expect(createMock).not.toHaveBeenCalled()
})
})
describe('/groups/:id', () => {
it('should parse path id and body for GET / PATCH / DELETE', async () => {
getByIdMock.mockResolvedValueOnce({ id: 'g1', entityType: 'topic', name: 'A' })
updateMock.mockResolvedValueOnce({ id: 'g1', entityType: 'topic', name: 'Renamed' })
deleteMock.mockResolvedValueOnce(undefined)
await expect(groupHandlers['/groups/:id'].GET({ params: { id: GROUP_ID } } as never)).resolves.toMatchObject({
id: 'g1'
})
await expect(
groupHandlers['/groups/:id'].PATCH({
params: { id: GROUP_ID },
body: { name: 'Renamed' }
} as never)
).resolves.toMatchObject({ name: 'Renamed' })
await expect(groupHandlers['/groups/:id'].DELETE({ params: { id: GROUP_ID } } as never)).resolves.toBeUndefined()
expect(getByIdMock).toHaveBeenCalledWith(GROUP_ID)
expect(updateMock).toHaveBeenCalledWith(GROUP_ID, { name: 'Renamed' })
expect(deleteMock).toHaveBeenCalledWith(GROUP_ID)
})
it('should reject an invalid group id in path params with ZodError', async () => {
await expect(groupHandlers['/groups/:id'].GET({ params: { id: 'not-a-uuid' } } as never)).rejects.toHaveProperty(
'name',
'ZodError'
)
expect(getByIdMock).not.toHaveBeenCalled()
})
it('should reject an invalid PATCH body (name type wrong) with ZodError', async () => {
await expect(
groupHandlers['/groups/:id'].PATCH({
params: { id: GROUP_ID },
body: { name: 123 }
} as never)
).rejects.toHaveProperty('name', 'ZodError')
expect(updateMock).not.toHaveBeenCalled()
})
})
describe('/groups/:id/order', () => {
it('should delegate a valid anchor body to groupService.reorder', async () => {
reorderMock.mockResolvedValueOnce(undefined)
await expect(
groupHandlers['/groups/:id/order'].PATCH({
params: { id: GROUP_ID },
body: { position: 'first' }
} as never)
).resolves.toBeUndefined()
expect(reorderMock).toHaveBeenCalledWith(GROUP_ID, { position: 'first' })
})
it('should reject an invalid anchor body with ZodError', async () => {
await expect(
groupHandlers['/groups/:id/order'].PATCH({
params: { id: GROUP_ID },
body: { position: 'middle' }
} as never)
).rejects.toHaveProperty('name', 'ZodError')
expect(reorderMock).not.toHaveBeenCalled()
})
it('should reject when id is not a valid uuid', async () => {
await expect(
groupHandlers['/groups/:id/order'].PATCH({
params: { id: 'not-a-uuid' },
body: { position: 'first' }
} as never)
).rejects.toHaveProperty('name', 'ZodError')
expect(reorderMock).not.toHaveBeenCalled()
})
})
describe('/groups/order:batch', () => {
it('should delegate a valid batch body to groupService.reorderBatch', async () => {
reorderBatchMock.mockResolvedValueOnce(undefined)
const moves = [
{ id: GROUP_ID, anchor: { position: 'first' } },
{ id: OTHER_GROUP_ID, anchor: { after: GROUP_ID } }
]
await expect(groupHandlers['/groups/order:batch'].PATCH({ body: { moves } } as never)).resolves.toBeUndefined()
expect(reorderBatchMock).toHaveBeenCalledWith(moves)
})
it('should reject an empty moves array with ZodError', async () => {
await expect(groupHandlers['/groups/order:batch'].PATCH({ body: { moves: [] } } as never)).rejects.toHaveProperty(
'name',
'ZodError'
)
expect(reorderBatchMock).not.toHaveBeenCalled()
})
it('should reject a malformed move entry with ZodError', async () => {
await expect(
groupHandlers['/groups/order:batch'].PATCH({
body: { moves: [{ id: '', anchor: { position: 'first' } }] }
} as never)
).rejects.toHaveProperty('name', 'ZodError')
expect(reorderBatchMock).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { listByEntityTypeMock, getByIdMock, pinMock, unpinMock, reorderMock, reorderBatchMock } = vi.hoisted(() => ({
listByEntityTypeMock: vi.fn(),
getByIdMock: vi.fn(),
pinMock: vi.fn(),
unpinMock: vi.fn(),
reorderMock: vi.fn(),
reorderBatchMock: vi.fn()
}))
vi.mock('@data/services/PinService', () => ({
pinService: {
listByEntityType: listByEntityTypeMock,
getById: getByIdMock,
pin: pinMock,
unpin: unpinMock,
reorder: reorderMock,
reorderBatch: reorderBatchMock
}
}))
import { pinHandlers } from '../pins'
const PIN_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_PIN_ID = '22222222-2222-4222-8222-222222222222'
const ENTITY_ID = '33333333-3333-4333-8333-333333333333'
describe('pinHandlers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('/pins', () => {
it('should delegate GET to pinService.listByEntityType with the parsed entityType', async () => {
listByEntityTypeMock.mockResolvedValueOnce([{ id: PIN_ID, entityType: 'topic', entityId: ENTITY_ID }])
const result = await pinHandlers['/pins'].GET({
query: { entityType: 'topic' }
} as never)
expect(listByEntityTypeMock).toHaveBeenCalledWith('topic')
expect(result).toEqual([{ id: PIN_ID, entityType: 'topic', entityId: ENTITY_ID }])
})
it('should reject GET when query.entityType is missing', async () => {
await expect(pinHandlers['/pins'].GET({ query: {} } as never)).rejects.toHaveProperty('name', 'ZodError')
expect(listByEntityTypeMock).not.toHaveBeenCalled()
})
it('should reject GET when query.entityType is not a known enum value', async () => {
await expect(pinHandlers['/pins'].GET({ query: { entityType: 'unknown' } } as never)).rejects.toHaveProperty(
'name',
'ZodError'
)
expect(listByEntityTypeMock).not.toHaveBeenCalled()
})
it('should delegate POST with parsed body (idempotency returns the same row on repeat calls)', async () => {
const row = {
id: PIN_ID,
entityType: 'topic',
entityId: ENTITY_ID,
orderKey: 'a0'
}
pinMock.mockResolvedValue(row)
const firstCall = { body: { entityType: 'topic', entityId: ENTITY_ID } }
await expect(pinHandlers['/pins'].POST(firstCall as never)).resolves.toMatchObject({ id: PIN_ID })
await expect(pinHandlers['/pins'].POST(firstCall as never)).resolves.toMatchObject({ id: PIN_ID })
expect(pinMock).toHaveBeenNthCalledWith(1, { entityType: 'topic', entityId: ENTITY_ID })
expect(pinMock).toHaveBeenNthCalledWith(2, { entityType: 'topic', entityId: ENTITY_ID })
})
it('should reject POST with an invalid entityType before calling the service', async () => {
await expect(
pinHandlers['/pins'].POST({
body: { entityType: 'bogus', entityId: ENTITY_ID }
} as never)
).rejects.toHaveProperty('name', 'ZodError')
expect(pinMock).not.toHaveBeenCalled()
})
it('should reject POST with an invalid entityId before calling the service', async () => {
await expect(
pinHandlers['/pins'].POST({
body: { entityType: 'topic', entityId: 'not-a-uuid' }
} as never)
).rejects.toHaveProperty('name', 'ZodError')
expect(pinMock).not.toHaveBeenCalled()
})
})
describe('/pins/:id', () => {
it('should delegate GET with the parsed id', async () => {
getByIdMock.mockResolvedValueOnce({ id: PIN_ID })
await expect(pinHandlers['/pins/:id'].GET({ params: { id: PIN_ID } } as never)).resolves.toEqual({
id: PIN_ID
})
expect(getByIdMock).toHaveBeenCalledWith(PIN_ID)
})
it('should delegate DELETE with the parsed id', async () => {
unpinMock.mockResolvedValueOnce(undefined)
await expect(pinHandlers['/pins/:id'].DELETE({ params: { id: PIN_ID } } as never)).resolves.toBeUndefined()
expect(unpinMock).toHaveBeenCalledWith(PIN_ID)
})
it('should reject invalid pin ids in path params before calling the service', async () => {
await expect(pinHandlers['/pins/:id'].GET({ params: { id: 'not-a-uuid' } } as never)).rejects.toHaveProperty(
'name',
'ZodError'
)
expect(getByIdMock).not.toHaveBeenCalled()
await expect(pinHandlers['/pins/:id'].DELETE({ params: { id: 'not-a-uuid' } } as never)).rejects.toHaveProperty(
'name',
'ZodError'
)
expect(unpinMock).not.toHaveBeenCalled()
})
})
describe('/pins/:id/order', () => {
it('should delegate PATCH with the parsed id and anchor', async () => {
reorderMock.mockResolvedValueOnce(undefined)
await expect(
pinHandlers['/pins/:id/order'].PATCH({
params: { id: PIN_ID },
body: { before: OTHER_PIN_ID }
} as never)
).resolves.toBeUndefined()
expect(reorderMock).toHaveBeenCalledWith(PIN_ID, { before: OTHER_PIN_ID })
})
it('should reject a malformed anchor before calling the service', async () => {
await expect(
pinHandlers['/pins/:id/order'].PATCH({
params: { id: PIN_ID },
body: { before: OTHER_PIN_ID, after: OTHER_PIN_ID }
} as never)
).rejects.toHaveProperty('name', 'ZodError')
expect(reorderMock).not.toHaveBeenCalled()
})
it('should reject an invalid pin id before calling the service', async () => {
await expect(
pinHandlers['/pins/:id/order'].PATCH({
params: { id: 'not-a-uuid' },
body: { position: 'first' }
} as never)
).rejects.toHaveProperty('name', 'ZodError')
expect(reorderMock).not.toHaveBeenCalled()
})
})
describe('/pins/order:batch', () => {
it('should delegate PATCH with the parsed moves array', async () => {
reorderBatchMock.mockResolvedValueOnce(undefined)
await expect(
pinHandlers['/pins/order:batch'].PATCH({
body: {
moves: [
{ id: PIN_ID, anchor: { position: 'first' } },
{ id: OTHER_PIN_ID, anchor: { after: PIN_ID } }
]
}
} as never)
).resolves.toBeUndefined()
expect(reorderBatchMock).toHaveBeenCalledWith([
{ id: PIN_ID, anchor: { position: 'first' } },
{ id: OTHER_PIN_ID, anchor: { after: PIN_ID } }
])
})
it('should reject an empty moves array before calling the service', async () => {
await expect(pinHandlers['/pins/order:batch'].PATCH({ body: { moves: [] } } as never)).rejects.toHaveProperty(
'name',
'ZodError'
)
expect(reorderBatchMock).not.toHaveBeenCalled()
})
it('should reject a move missing an anchor before calling the service', async () => {
await expect(
pinHandlers['/pins/order:batch'].PATCH({
body: { moves: [{ id: PIN_ID }] }
} as never)
).rejects.toHaveProperty('name', 'ZodError')
expect(reorderBatchMock).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,78 @@
/**
* Group API Handlers
*
* Implements all group-related API endpoints:
* - Group CRUD operations
* - Scoped reorder endpoints (single + batch)
*
* All input validation happens here at the system boundary — handlers do not
* perform row lookups or scope inference, those responsibilities belong to
* `GroupService`.
*/
import { groupService } from '@data/services/GroupService'
import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes'
import { OrderBatchRequestSchema, OrderRequestSchema } from '@shared/data/api/schemas/_endpointHelpers'
import type { GroupSchemas } from '@shared/data/api/schemas/groups'
import {
CreateGroupDtoSchema,
GroupIdSchema,
ListGroupsQuerySchema,
UpdateGroupDtoSchema
} from '@shared/data/api/schemas/groups'
type GroupHandler<Path extends keyof GroupSchemas, Method extends ApiMethods<Path>> = ApiHandler<Path, Method>
export const groupHandlers: {
[Path in keyof GroupSchemas]: {
[Method in keyof GroupSchemas[Path]]: GroupHandler<Path, Method & ApiMethods<Path>>
}
} = {
'/groups': {
GET: async ({ query }) => {
const parsed = ListGroupsQuerySchema.parse(query)
return await groupService.listByEntityType(parsed.entityType)
},
POST: async ({ body }) => {
const parsed = CreateGroupDtoSchema.parse(body)
return await groupService.create(parsed)
}
},
'/groups/:id': {
GET: async ({ params }) => {
const id = GroupIdSchema.parse(params.id)
return await groupService.getById(id)
},
PATCH: async ({ params, body }) => {
const id = GroupIdSchema.parse(params.id)
const parsed = UpdateGroupDtoSchema.parse(body)
return await groupService.update(id, parsed)
},
DELETE: async ({ params }) => {
const id = GroupIdSchema.parse(params.id)
await groupService.delete(id)
return undefined
}
},
'/groups/:id/order': {
PATCH: async ({ params, body }) => {
const id = GroupIdSchema.parse(params.id)
const anchor = OrderRequestSchema.parse(body)
await groupService.reorder(id, anchor)
return undefined
}
},
'/groups/order:batch': {
PATCH: async ({ body }) => {
const parsed = OrderBatchRequestSchema.parse(body)
await groupService.reorderBatch(parsed.moves)
return undefined
}
}
}

View File

@@ -16,11 +16,13 @@ import type { ApiImplementation } from '@shared/data/api/apiTypes'
import { assistantHandlers } from './assistants'
import { fileProcessingHandlers } from './fileProcessing'
import { groupHandlers } from './groups'
import { knowledgeHandlers } from './knowledges'
import { mcpServerHandlers } from './mcpServers'
import { messageHandlers } from './messages'
import { miniappHandlers } from './miniapps'
import { modelHandlers } from './models'
import { pinHandlers } from './pins'
import { providerHandlers } from './providers'
import { tagHandlers } from './tags'
import { temporaryChatHandlers } from './temporaryChats'
@@ -46,5 +48,7 @@ export const apiHandlers: ApiImplementation = {
...translateHandlers,
...mcpServerHandlers,
...miniappHandlers,
...tagHandlers
...tagHandlers,
...groupHandlers,
...pinHandlers
}

View File

@@ -0,0 +1,66 @@
/**
* Pin API Handlers
*
* Implements all pin-related API endpoints:
* - Pin CRUD (list by entityType, idempotent pin, get, unpin)
* - Scoped reorder (single + batch)
*
* All input validation happens here at the system boundary. Business logic —
* scope inference, orderKey computation, concurrency handling — lives in
* PinService.
*/
import { pinService } from '@data/services/PinService'
import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes'
import { OrderBatchRequestSchema, OrderRequestSchema } from '@shared/data/api/schemas/_endpointHelpers'
import { CreatePinDtoSchema, ListPinsQuerySchema, PinIdSchema, type PinSchemas } from '@shared/data/api/schemas/pins'
type PinHandler<Path extends keyof PinSchemas, Method extends ApiMethods<Path>> = ApiHandler<Path, Method>
export const pinHandlers: {
[Path in keyof PinSchemas]: {
[Method in keyof PinSchemas[Path]]: PinHandler<Path, Method & ApiMethods<Path>>
}
} = {
'/pins': {
GET: async ({ query }) => {
const parsed = ListPinsQuerySchema.parse(query)
return await pinService.listByEntityType(parsed.entityType)
},
POST: async ({ body }) => {
const parsed = CreatePinDtoSchema.parse(body)
return await pinService.pin(parsed)
}
},
'/pins/:id': {
GET: async ({ params }) => {
const id = PinIdSchema.parse(params.id)
return await pinService.getById(id)
},
DELETE: async ({ params }) => {
const id = PinIdSchema.parse(params.id)
await pinService.unpin(id)
return undefined
}
},
'/pins/:id/order': {
PATCH: async ({ params, body }) => {
const id = PinIdSchema.parse(params.id)
const anchor = OrderRequestSchema.parse(body)
await pinService.reorder(id, anchor)
return undefined
}
},
'/pins/order:batch': {
PATCH: async ({ body }) => {
const parsed = OrderBatchRequestSchema.parse(body)
await pinService.reorderBatch(parsed.moves)
return undefined
}
}
}

View File

@@ -1,24 +1,25 @@
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateTimestamps, uuidPrimaryKey } from './_columnHelpers'
import { createUpdateTimestamps, orderKeyColumns, scopedOrderKeyIndex, uuidPrimaryKey } from './_columnHelpers'
/**
* Group table - general-purpose grouping for entities
*
* Supports grouping of topics, sessions, and assistants.
* Each group belongs to a specific entity type.
* Each group belongs to a specific entity type; ordering is scoped per entityType
* via a fractional-indexing `orderKey` (see services/utils/orderKey.ts).
*/
export const groupTable = sqliteTable(
'group',
{
id: uuidPrimaryKey(),
// Entity type this group belongs to: topic, session, assistant
entityType: text().notNull(),
// Display name of the group
name: text().notNull(),
// Sort order for display
sortOrder: integer().default(0),
...orderKeyColumns,
...createUpdateTimestamps
},
(t) => [index('group_entity_sort_idx').on(t.entityType, t.sortOrder)]
(t) => [scopedOrderKeyIndex('group', 'entityType')(t)]
)
export type GroupInsert = typeof groupTable.$inferInsert
export type GroupSelect = typeof groupTable.$inferSelect

View File

@@ -0,0 +1,42 @@
import { sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { createUpdateTimestamps, orderKeyColumns, scopedOrderKeyIndex, uuidPrimaryKey } from './_columnHelpers'
/**
* Pin table - polymorphic pinning across entity types
*
* Any entity type (topic, session, assistant, ...) can be pinned by inserting
* a row here with (entityType, entityId). Pinning is non-destructive: the
* referenced entity's group / order / state is unaffected. Pin order is
* scoped per entityType via `orderKey`.
*
* Design notes:
* - Polymorphic (no FK): mirrors the `entity_tag` table. Consumers MUST call
* `PinService.purgeForEntity(tx, entityType, entityId)` in their delete paths
* (cf. `tagService.removeEntityTags`) — the infra layer has zero knowledge
* of consumer schemas by design.
* - Hard delete on unpin: pinning is a non-destructive marker with no business
* audit value. Keeping a `deletedAt` column would let dead rows accumulate
* for a feature that only tracks "is this currently pinned?".
* - UNIQUE(entityType, entityId): enforces idempotency at the DB layer. The
* service-layer `pin()` method converts the resulting UNIQUE violation back
* into "return the existing row" so the DataApi boundary stays idempotent
* under concurrency.
*/
export const pinTable = sqliteTable(
'pin',
{
id: uuidPrimaryKey(),
entityType: text().notNull(),
entityId: text().notNull(),
...orderKeyColumns,
...createUpdateTimestamps
},
(t) => [
uniqueIndex('pin_entity_type_entity_id_unique_idx').on(t.entityType, t.entityId),
scopedOrderKeyIndex('pin', 'entityType')(t)
]
)
export type PinInsert = typeof pinTable.$inferInsert
export type PinSelect = typeof pinTable.$inferSelect

View File

@@ -0,0 +1,164 @@
/**
* Group Service - handles group CRUD and scoped reorder operations
*
* Groups are user-managed flat containers keyed by `entityType`. Ordering within
* an entityType bucket is preserved via a fractional-indexing `orderKey`.
*
* USAGE GUIDANCE:
* - `listByEntityType` is the canonical read path; `entityType` is always required.
* - `create` auto-assigns `orderKey` via `insertWithOrderKey` (scope=entityType)
* so consumers never touch the column directly.
* - `reorder` / `reorderBatch` delegate to `applyScopedMoves`, which performs
* scope inference and enforces "batch stays within one entityType".
*/
import { application } from '@application'
import { groupTable } from '@data/db/schemas/group'
import { defaultHandlersFor, withSqliteErrors } from '@data/db/sqliteErrors'
import { loggerService } from '@logger'
import { DataApiErrorFactory } from '@shared/data/api'
import type { OrderRequest } from '@shared/data/api/schemas/_endpointHelpers'
import type { CreateGroupDto, UpdateGroupDto } from '@shared/data/api/schemas/groups'
import type { EntityType } from '@shared/data/types/entityType'
import type { Group } from '@shared/data/types/group'
import { asc, eq } from 'drizzle-orm'
import { applyScopedMoves, insertWithOrderKey } from './utils/orderKey'
import { timestampToISO } from './utils/rowMappers'
const logger = loggerService.withContext('DataApi:GroupService')
type GroupRow = typeof groupTable.$inferSelect
function rowToGroup(row: GroupRow): Group {
return {
id: row.id,
entityType: row.entityType as EntityType,
name: row.name,
orderKey: row.orderKey,
createdAt: timestampToISO(row.createdAt),
updatedAt: timestampToISO(row.updatedAt)
}
}
export class GroupService {
private get db() {
return application.get('DbService').getDb()
}
/**
* List groups for a given entityType, ordered by orderKey ASC.
*/
async listByEntityType(entityType: EntityType): Promise<Group[]> {
const rows = await this.db
.select()
.from(groupTable)
.where(eq(groupTable.entityType, entityType))
.orderBy(asc(groupTable.orderKey))
return rows.map(rowToGroup)
}
/**
* Get a group by ID.
*/
async getById(id: string): Promise<Group> {
const [row] = await this.db.select().from(groupTable).where(eq(groupTable.id, id)).limit(1)
if (!row) {
throw DataApiErrorFactory.notFound('Group', id)
}
return rowToGroup(row)
}
/**
* Create a new group. The new row is appended to the end of its entityType
* bucket with a fresh fractional-indexing orderKey.
*/
async create(dto: CreateGroupDto): Promise<Group> {
const row = await withSqliteErrors(
() =>
this.db.transaction(async (tx) =>
insertWithOrderKey(
tx,
groupTable,
{ entityType: dto.entityType, name: dto.name },
{
pkColumn: groupTable.id,
scope: eq(groupTable.entityType, dto.entityType)
}
)
),
defaultHandlersFor('Group', dto.name)
)
const mapped = rowToGroup(row as GroupRow)
logger.info('Created group', { id: mapped.id, entityType: mapped.entityType })
return mapped
}
/**
* Update an existing group. `entityType` is immutable — only `name` can change.
*/
async update(id: string, dto: UpdateGroupDto): Promise<Group> {
const updates: Partial<typeof groupTable.$inferInsert> = {}
if (dto.name !== undefined) updates.name = dto.name
if (Object.keys(updates).length === 0) {
return this.getById(id)
}
const [row] = await withSqliteErrors(
() => this.db.update(groupTable).set(updates).where(eq(groupTable.id, id)).returning(),
defaultHandlersFor('Group', dto.name ?? id)
)
if (!row) {
throw DataApiErrorFactory.notFound('Group', id)
}
logger.info('Updated group', { id, changes: Object.keys(dto) })
return rowToGroup(row)
}
/**
* Delete a group.
*/
async delete(id: string): Promise<void> {
const [row] = await this.db.delete(groupTable).where(eq(groupTable.id, id)).returning({ id: groupTable.id })
if (!row) {
throw DataApiErrorFactory.notFound('Group', id)
}
logger.info('Deleted group', { id })
}
/**
* Move a single group relative to an anchor. Scope (entityType) is inferred
* from the target row — callers do not pass scope.
*/
async reorder(id: string, anchor: OrderRequest): Promise<void> {
await this.db.transaction(async (tx) =>
applyScopedMoves(tx, groupTable, [{ id, anchor }], {
pkColumn: groupTable.id,
scopeColumn: groupTable.entityType
})
)
}
/**
* Apply a batch of moves atomically. `applyScopedMoves` rejects batches that
* span multiple entityTypes with a VALIDATION_ERROR.
*/
async reorderBatch(moves: Array<{ id: string; anchor: OrderRequest }>): Promise<void> {
await this.db.transaction(async (tx) =>
applyScopedMoves(tx, groupTable, moves, {
pkColumn: groupTable.id,
scopeColumn: groupTable.entityType
})
)
}
}
export const groupService = new GroupService()

View File

@@ -0,0 +1,193 @@
/**
* Pin Service - handles polymorphic pin CRUD and scoped reorder operations
*
* Pins are a non-destructive "promote to top" marker for any entity type
* listed in the shared `EntityType` enum. Ordering within an entityType bucket
* is preserved via a fractional-indexing `orderKey`.
*
* USAGE GUIDANCE:
* - `listByEntityType` is the canonical read path; `entityType` is always required.
* - `pin` is idempotent AND concurrent-safe: repeat calls for the same
* (entityType, entityId) resolve to the same row, even under parallel writes.
* - `unpin` is a hard delete. There is no soft-delete / audit column.
* - `reorder` / `reorderBatch` delegate to `applyScopedMoves`, which performs
* scope inference and enforces "batch stays within one entityType".
* - `purgeForEntity` MUST be called from consumer services' delete paths
* (mirrors `tagService.removeEntityTags`). The `pin` table has no FK to
* consumer tables by design; application-level purge is the contract.
*/
import { application } from '@application'
import { type PinSelect, pinTable } from '@data/db/schemas/pin'
import { classifySqliteError } from '@data/db/sqliteErrors'
import type { DbType } from '@data/db/types'
import { loggerService } from '@logger'
import { DataApiErrorFactory } from '@shared/data/api'
import type { OrderRequest } from '@shared/data/api/schemas/_endpointHelpers'
import type { CreatePinDto } from '@shared/data/api/schemas/pins'
import type { EntityType } from '@shared/data/types/entityType'
import type { Pin } from '@shared/data/types/pin'
import { and, asc, eq } from 'drizzle-orm'
import { applyScopedMoves, insertWithOrderKey } from './utils/orderKey'
import { timestampToISO } from './utils/rowMappers'
const logger = loggerService.withContext('DataApi:PinService')
function rowToPin(row: PinSelect): Pin {
return {
id: row.id,
entityType: row.entityType as EntityType,
entityId: row.entityId,
orderKey: row.orderKey,
createdAt: timestampToISO(row.createdAt),
updatedAt: timestampToISO(row.updatedAt)
}
}
export class PinService {
private get db() {
return application.get('DbService').getDb()
}
/**
* List pins for a given entityType, ordered by orderKey ASC.
*/
async listByEntityType(entityType: EntityType): Promise<Pin[]> {
const rows = await this.db
.select()
.from(pinTable)
.where(eq(pinTable.entityType, entityType))
.orderBy(asc(pinTable.orderKey))
return rows.map(rowToPin)
}
/**
* Get a pin by ID.
*/
async getById(id: string): Promise<Pin> {
const [row] = await this.db.select().from(pinTable).where(eq(pinTable.id, id)).limit(1)
if (!row) {
throw DataApiErrorFactory.notFound('Pin', id)
}
return rowToPin(row)
}
/**
* Idempotent, concurrent-safe pin. Two sequential calls with the same
* (entityType, entityId) return the same row; two concurrent calls also
* converge to one row without leaking a UNIQUE violation to the caller.
*
* Strategy: fast-path SELECT first; if nothing is there, INSERT with scoped
* orderKey. Under concurrency the INSERT may race a peer's INSERT and hit
* the UNIQUE(entityType, entityId) index — in that case classify the error
* as `unique` and re-SELECT to return the winner's row. Any non-UNIQUE
* error is re-thrown unchanged.
*
* See sqliteErrors.ts "Discipline: do not replace pre-validation" — the
* fast-path SELECT IS the pre-validation here; the UNIQUE catch is purely
* the TOCTOU concurrency fallback.
*/
async pin(dto: CreatePinDto): Promise<Pin> {
return this.db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(pinTable)
.where(and(eq(pinTable.entityType, dto.entityType), eq(pinTable.entityId, dto.entityId)))
.limit(1)
if (existing) return rowToPin(existing)
try {
const inserted = await insertWithOrderKey(
tx,
pinTable,
{ entityType: dto.entityType, entityId: dto.entityId },
{
pkColumn: pinTable.id,
scope: eq(pinTable.entityType, dto.entityType)
}
)
const mapped = rowToPin(inserted as PinSelect)
logger.info('Created pin', {
id: mapped.id,
entityType: mapped.entityType,
entityId: mapped.entityId
})
return mapped
} catch (e) {
if (classifySqliteError(e)?.kind !== 'unique') throw e
const [winner] = await tx
.select()
.from(pinTable)
.where(and(eq(pinTable.entityType, dto.entityType), eq(pinTable.entityId, dto.entityId)))
.limit(1)
if (!winner) throw e
return rowToPin(winner)
}
})
}
/**
* Unpin by pin id. Hard delete.
*/
async unpin(id: string): Promise<void> {
const [row] = await this.db.delete(pinTable).where(eq(pinTable.id, id)).returning({ id: pinTable.id })
if (!row) {
throw DataApiErrorFactory.notFound('Pin', id)
}
logger.info('Deleted pin', { id })
}
/**
* Move a single pin relative to an anchor. Scope (entityType) is inferred
* from the target row — callers do not pass scope.
*/
async reorder(id: string, anchor: OrderRequest): Promise<void> {
await this.db.transaction(async (tx) =>
applyScopedMoves(tx, pinTable, [{ id, anchor }], {
pkColumn: pinTable.id,
scopeColumn: pinTable.entityType
})
)
}
/**
* Apply a batch of moves atomically. `applyScopedMoves` rejects batches that
* span multiple entityTypes with a VALIDATION_ERROR.
*/
async reorderBatch(moves: Array<{ id: string; anchor: OrderRequest }>): Promise<void> {
await this.db.transaction(async (tx) =>
applyScopedMoves(tx, pinTable, moves, {
pkColumn: pinTable.id,
scopeColumn: pinTable.entityType
})
)
}
/**
* Remove all pin rows targeting a given (entityType, entityId).
* Must be called by consumer services (TopicService, AssistantService, ...)
* when deleting the underlying entity, since `pin` has no FK to entity
* tables.
*
* Because pin is hard-deleted row-by-row (no bulk orderKey rewrite), the
* remaining rows' orderKeys are not mutated — neighbors retain their
* existing keys and relative ordering.
*
* Signature is tx-first (mainstream ORM convention) rather than mirroring
* `tagService.removeEntityTags` — tag's trailing-optional-tx is the project's
* historical outlier; new polymorphic purge helpers should lead with tx.
*/
async purgeForEntity(tx: Pick<DbType, 'delete'>, entityType: EntityType, entityId: string): Promise<void> {
await tx.delete(pinTable).where(and(eq(pinTable.entityType, entityType), eq(pinTable.entityId, entityId)))
logger.info('Purged pins for entity', { entityType, entityId })
}
}
export const pinService = new PinService()

View File

@@ -0,0 +1,208 @@
import { groupTable } from '@data/db/schemas/group'
import { GroupService, groupService } from '@data/services/GroupService'
import { DataApiError, ErrorCode } from '@shared/data/api'
import { setupTestDatabase } from '@test-helpers/db'
import { eq } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
const GROUP_ID_MISSING = '11111111-1111-4111-8111-111111111111'
describe('GroupService', () => {
const dbh = setupTestDatabase()
it('should export a module-level singleton of GroupService', () => {
expect(groupService).toBeInstanceOf(GroupService)
})
describe('create', () => {
it('should create a group with an auto-assigned orderKey', async () => {
const result = await groupService.create({ entityType: 'topic', name: 'Research' })
expect(result).toMatchObject({ entityType: 'topic', name: 'Research' })
expect(typeof result.orderKey).toBe('string')
expect(result.orderKey.length).toBeGreaterThan(0)
const [row] = await dbh.db.select().from(groupTable).where(eq(groupTable.id, result.id))
expect(row).toMatchObject({ name: 'Research', entityType: 'topic', orderKey: result.orderKey })
})
it('should assign strictly increasing orderKeys within the same entityType', async () => {
const first = await groupService.create({ entityType: 'topic', name: 'alpha' })
const second = await groupService.create({ entityType: 'topic', name: 'beta' })
const third = await groupService.create({ entityType: 'topic', name: 'gamma' })
expect(second.orderKey > first.orderKey).toBe(true)
expect(third.orderKey > second.orderKey).toBe(true)
})
it('should keep orderKey sequences independent across entityTypes', async () => {
const topicFirst = await groupService.create({ entityType: 'topic', name: 'first-topic' })
const sessionFirst = await groupService.create({ entityType: 'session', name: 'first-session' })
// Each entityType starts with the same fractional-indexing starter key
// because neither bucket has a predecessor.
expect(topicFirst.orderKey).toBe(sessionFirst.orderKey)
})
})
describe('listByEntityType', () => {
it('should return groups ordered by orderKey, scoped to the requested entityType', async () => {
const topicA = await groupService.create({ entityType: 'topic', name: 'A' })
const topicB = await groupService.create({ entityType: 'topic', name: 'B' })
await groupService.create({ entityType: 'session', name: 'session-only' })
const topics = await groupService.listByEntityType('topic')
expect(topics.map((g) => g.id)).toEqual([topicA.id, topicB.id])
})
it('should return an empty array when no groups exist for the entityType', async () => {
await expect(groupService.listByEntityType('assistant')).resolves.toEqual([])
})
})
describe('getById', () => {
it('should throw NOT_FOUND when the group does not exist', async () => {
await expect(groupService.getById(GROUP_ID_MISSING)).rejects.toThrow(DataApiError)
await expect(groupService.getById(GROUP_ID_MISSING)).rejects.toMatchObject({
code: ErrorCode.NOT_FOUND
})
})
})
describe('update', () => {
it('should update the name of an existing group', async () => {
const created = await groupService.create({ entityType: 'topic', name: 'Old' })
const updated = await groupService.update(created.id, { name: 'New' })
expect(updated).toMatchObject({ id: created.id, name: 'New', entityType: 'topic' })
})
it('should return the current row for an empty update payload', async () => {
const created = await groupService.create({ entityType: 'topic', name: 'Unchanged' })
const result = await groupService.update(created.id, {})
expect(result).toMatchObject({ id: created.id, name: 'Unchanged' })
})
it('should throw NOT_FOUND when the group does not exist', async () => {
await expect(groupService.update(GROUP_ID_MISSING, { name: 'x' })).rejects.toMatchObject({
code: ErrorCode.NOT_FOUND
})
})
})
describe('reorder', () => {
it("should move a group to the first position via { position: 'first' }", async () => {
const a = await groupService.create({ entityType: 'topic', name: 'A' })
const b = await groupService.create({ entityType: 'topic', name: 'B' })
const c = await groupService.create({ entityType: 'topic', name: 'C' })
await groupService.reorder(c.id, { position: 'first' })
const ids = (await groupService.listByEntityType('topic')).map((g) => g.id)
expect(ids).toEqual([c.id, a.id, b.id])
})
it('should move a group to before an anchor', async () => {
const a = await groupService.create({ entityType: 'topic', name: 'A' })
const b = await groupService.create({ entityType: 'topic', name: 'B' })
const c = await groupService.create({ entityType: 'topic', name: 'C' })
await groupService.reorder(c.id, { before: b.id })
const ids = (await groupService.listByEntityType('topic')).map((g) => g.id)
expect(ids).toEqual([a.id, c.id, b.id])
})
it('should move a group to after an anchor', async () => {
const a = await groupService.create({ entityType: 'topic', name: 'A' })
const b = await groupService.create({ entityType: 'topic', name: 'B' })
const c = await groupService.create({ entityType: 'topic', name: 'C' })
await groupService.reorder(a.id, { after: b.id })
const ids = (await groupService.listByEntityType('topic')).map((g) => g.id)
expect(ids).toEqual([b.id, a.id, c.id])
})
it("should move a group to the last position via { position: 'last' }", async () => {
const a = await groupService.create({ entityType: 'topic', name: 'A' })
const b = await groupService.create({ entityType: 'topic', name: 'B' })
const c = await groupService.create({ entityType: 'topic', name: 'C' })
await groupService.reorder(a.id, { position: 'last' })
const ids = (await groupService.listByEntityType('topic')).map((g) => g.id)
expect(ids).toEqual([b.id, c.id, a.id])
})
it('should throw NOT_FOUND when the target id does not exist', async () => {
await expect(groupService.reorder(GROUP_ID_MISSING, { position: 'first' })).rejects.toMatchObject({
code: ErrorCode.NOT_FOUND
})
})
})
describe('reorderBatch', () => {
it('should apply multi-move atomically within one entityType', async () => {
const a = await groupService.create({ entityType: 'topic', name: 'A' })
const b = await groupService.create({ entityType: 'topic', name: 'B' })
const c = await groupService.create({ entityType: 'topic', name: 'C' })
const d = await groupService.create({ entityType: 'topic', name: 'D' })
await groupService.reorderBatch([
{ id: d.id, anchor: { position: 'first' } },
{ id: a.id, anchor: { position: 'last' } }
])
const ids = (await groupService.listByEntityType('topic')).map((g) => g.id)
expect(ids).toEqual([d.id, b.id, c.id, a.id])
})
it('should reject a batch spanning multiple entityTypes with VALIDATION_ERROR', async () => {
const topic = await groupService.create({ entityType: 'topic', name: 'topic-group' })
const session = await groupService.create({ entityType: 'session', name: 'session-group' })
await expect(
groupService.reorderBatch([
{ id: topic.id, anchor: { position: 'first' } },
{ id: session.id, anchor: { position: 'first' } }
])
).rejects.toMatchObject({ code: ErrorCode.VALIDATION_ERROR })
})
it('should throw NOT_FOUND when any move id is unknown', async () => {
const a = await groupService.create({ entityType: 'topic', name: 'A' })
await expect(
groupService.reorderBatch([
{ id: a.id, anchor: { position: 'last' } },
{ id: GROUP_ID_MISSING, anchor: { position: 'first' } }
])
).rejects.toMatchObject({ code: ErrorCode.NOT_FOUND })
})
})
describe('delete', () => {
it('should not change orderKeys of sibling groups after a deletion', async () => {
const a = await groupService.create({ entityType: 'topic', name: 'A' })
const b = await groupService.create({ entityType: 'topic', name: 'B' })
const c = await groupService.create({ entityType: 'topic', name: 'C' })
await groupService.delete(b.id)
const remaining = await groupService.listByEntityType('topic')
expect(remaining.map((g) => g.id)).toEqual([a.id, c.id])
expect(remaining[0].orderKey).toBe(a.orderKey)
expect(remaining[1].orderKey).toBe(c.orderKey)
})
it('should throw NOT_FOUND when the group does not exist', async () => {
await expect(groupService.delete(GROUP_ID_MISSING)).rejects.toMatchObject({
code: ErrorCode.NOT_FOUND
})
})
})
})

View File

@@ -0,0 +1,241 @@
import { pinTable } from '@data/db/schemas/pin'
import { PinService, pinService } from '@data/services/PinService'
import { DataApiError, ErrorCode } from '@shared/data/api'
import { setupTestDatabase } from '@test-helpers/db'
import { eq } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
const PIN_ID_MISSING = '11111111-1111-4111-8111-111111111111'
const ENTITY_ID_1 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'
const ENTITY_ID_2 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2'
const ENTITY_ID_3 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3'
const PREEXISTING_PIN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
describe('PinService', () => {
const dbh = setupTestDatabase()
it('should export a module-level singleton of PinService', () => {
expect(pinService).toBeInstanceOf(PinService)
})
describe('pin', () => {
it('should insert a new row for a fresh (entityType, entityId) pair', async () => {
const result = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
expect(result).toMatchObject({ entityType: 'topic', entityId: ENTITY_ID_1 })
expect(typeof result.orderKey).toBe('string')
expect(result.orderKey.length).toBeGreaterThan(0)
const [row] = await dbh.db.select().from(pinTable).where(eq(pinTable.id, result.id))
expect(row).toMatchObject({ entityType: 'topic', entityId: ENTITY_ID_1, orderKey: result.orderKey })
})
it('should return the same row on a repeat call (serial idempotency)', async () => {
const first = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
const second = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
expect(second.id).toBe(first.id)
expect(second.orderKey).toBe(first.orderKey)
const rows = await dbh.db.select().from(pinTable)
expect(rows).toHaveLength(1)
})
it('should return the pre-existing row when (entityType, entityId) already present', async () => {
// Seed a row directly to simulate "already pinned before this call runs".
// Exercises the fast-path SELECT branch of the idempotent pin().
await dbh.db.insert(pinTable).values({
id: PREEXISTING_PIN_ID,
entityType: 'topic',
entityId: ENTITY_ID_1,
orderKey: 'a0',
createdAt: 1_000,
updatedAt: 1_000
})
const result = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
expect(result.id).toBe(PREEXISTING_PIN_ID)
expect(result.orderKey).toBe('a0')
const rows = await dbh.db.select().from(pinTable)
expect(rows).toHaveLength(1)
})
it('should return distinct rows for different (entityType, entityId) pairs', async () => {
const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
const b = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 })
const c = await pinService.pin({ entityType: 'session', entityId: ENTITY_ID_1 })
const ids = new Set([a.id, b.id, c.id])
expect(ids.size).toBe(3)
})
it('should maintain independent orderKey sequences per entityType', async () => {
const topicFirst = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
const sessionFirst = await pinService.pin({ entityType: 'session', entityId: ENTITY_ID_1 })
// Each scope starts from the same fractional-indexing starter key because
// neither bucket has a predecessor.
expect(topicFirst.orderKey).toBe(sessionFirst.orderKey)
const topicSecond = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 })
expect(topicSecond.orderKey > topicFirst.orderKey).toBe(true)
})
})
describe('unpin', () => {
it('should hard delete the pin row and return void', async () => {
const pin = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
await expect(pinService.unpin(pin.id)).resolves.toBeUndefined()
const rows = await dbh.db.select().from(pinTable).where(eq(pinTable.id, pin.id))
expect(rows).toHaveLength(0)
})
it('should throw NOT_FOUND when the pin id is unknown', async () => {
await expect(pinService.unpin(PIN_ID_MISSING)).rejects.toThrow(DataApiError)
await expect(pinService.unpin(PIN_ID_MISSING)).rejects.toMatchObject({
code: ErrorCode.NOT_FOUND
})
})
})
describe('getById', () => {
it('should return a fully mapped pin when found', async () => {
const pin = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
const result = await pinService.getById(pin.id)
expect(result).toMatchObject({ id: pin.id, entityType: 'topic', entityId: ENTITY_ID_1 })
})
it('should throw NOT_FOUND when the pin does not exist', async () => {
await expect(pinService.getById(PIN_ID_MISSING)).rejects.toMatchObject({
code: ErrorCode.NOT_FOUND
})
})
})
describe('listByEntityType', () => {
it('should return pins ordered by orderKey, scoped to the requested entityType', async () => {
const topicA = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
const topicB = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 })
await pinService.pin({ entityType: 'session', entityId: ENTITY_ID_1 })
const topics = await pinService.listByEntityType('topic')
expect(topics.map((p) => p.id)).toEqual([topicA.id, topicB.id])
})
it('should return an empty array when no pins exist for the entityType', async () => {
await expect(pinService.listByEntityType('assistant')).resolves.toEqual([])
})
})
describe('reorder', () => {
it("should move a pin to the first position via { position: 'first' }", async () => {
const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
const b = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 })
const c = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_3 })
await pinService.reorder(c.id, { position: 'first' })
const ids = (await pinService.listByEntityType('topic')).map((p) => p.id)
expect(ids).toEqual([c.id, a.id, b.id])
})
it('should move a pin to before an anchor', async () => {
const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
const b = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 })
const c = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_3 })
await pinService.reorder(c.id, { before: b.id })
const ids = (await pinService.listByEntityType('topic')).map((p) => p.id)
expect(ids).toEqual([a.id, c.id, b.id])
})
it('should throw NOT_FOUND when the target id does not exist', async () => {
await expect(pinService.reorder(PIN_ID_MISSING, { position: 'first' })).rejects.toMatchObject({
code: ErrorCode.NOT_FOUND
})
})
})
describe('reorderBatch', () => {
it('should apply multi-move atomically within one entityType', async () => {
const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
const b = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 })
const c = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_3 })
const d = await pinService.pin({ entityType: 'topic', entityId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4' })
await pinService.reorderBatch([
{ id: d.id, anchor: { position: 'first' } },
{ id: a.id, anchor: { position: 'last' } }
])
const ids = (await pinService.listByEntityType('topic')).map((p) => p.id)
expect(ids).toEqual([d.id, b.id, c.id, a.id])
})
it('should reject a batch spanning multiple entityTypes with VALIDATION_ERROR', async () => {
const topic = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
const session = await pinService.pin({ entityType: 'session', entityId: ENTITY_ID_1 })
await expect(
pinService.reorderBatch([
{ id: topic.id, anchor: { position: 'first' } },
{ id: session.id, anchor: { position: 'first' } }
])
).rejects.toMatchObject({ code: ErrorCode.VALIDATION_ERROR })
})
it('should throw NOT_FOUND when any move id is unknown', async () => {
const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
await expect(
pinService.reorderBatch([
{ id: a.id, anchor: { position: 'last' } },
{ id: PIN_ID_MISSING, anchor: { position: 'first' } }
])
).rejects.toMatchObject({ code: ErrorCode.NOT_FOUND })
})
})
describe('purgeForEntity', () => {
it('should delete only pins targeting the specified (entityType, entityId)', async () => {
const target = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
const siblingSameType = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 })
const sameIdOtherType = await pinService.pin({ entityType: 'session', entityId: ENTITY_ID_1 })
await pinService.purgeForEntity(dbh.db, 'topic', ENTITY_ID_1)
const rows = await dbh.db.select().from(pinTable)
const remainingIds = rows.map((r) => r.id).sort()
expect(remainingIds).toEqual([siblingSameType.id, sameIdOtherType.id].sort())
expect(rows.find((r) => r.id === target.id)).toBeUndefined()
})
it('should not mutate neighbor orderKeys within the same entityType', async () => {
const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
const b = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 })
const c = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_3 })
await pinService.purgeForEntity(dbh.db, 'topic', b.entityId)
const remaining = await pinService.listByEntityType('topic')
expect(remaining.map((p) => p.id)).toEqual([a.id, c.id])
expect(remaining[0].orderKey).toBe(a.orderKey)
expect(remaining[1].orderKey).toBe(c.orderKey)
})
it('should be a no-op when no matching pin exists', async () => {
const existing = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 })
await expect(pinService.purgeForEntity(dbh.db, 'assistant', ENTITY_ID_1)).resolves.toBeUndefined()
const rows = await dbh.db.select().from(pinTable)
expect(rows.map((r) => r.id)).toEqual([existing.id])
})
})
})