mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
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:
15
migrations/sqlite-drizzle/0014_hot_ultron.sql
Normal file
15
migrations/sqlite-drizzle/0014_hot_ultron.sql
Normal 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`;
|
||||
3032
migrations/sqlite-drizzle/meta/0014_snapshot.json
Normal file
3032
migrations/sqlite-drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
97
packages/shared/data/api/schemas/groups.ts
Normal file
97
packages/shared/data/api/schemas/groups.ts
Normal 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'>
|
||||
@@ -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
|
||||
>
|
||||
|
||||
86
packages/shared/data/api/schemas/pins.ts
Normal file
86
packages/shared/data/api/schemas/pins.ts
Normal 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'>
|
||||
37
packages/shared/data/types/group.ts
Normal file
37
packages/shared/data/types/group.ts
Normal 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>
|
||||
36
packages/shared/data/types/pin.ts
Normal file
36
packages/shared/data/types/pin.ts
Normal 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>
|
||||
213
src/main/data/api/handlers/__tests__/groups.test.ts
Normal file
213
src/main/data/api/handlers/__tests__/groups.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
200
src/main/data/api/handlers/__tests__/pins.test.ts
Normal file
200
src/main/data/api/handlers/__tests__/pins.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
78
src/main/data/api/handlers/groups.ts
Normal file
78
src/main/data/api/handlers/groups.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
66
src/main/data/api/handlers/pins.ts
Normal file
66
src/main/data/api/handlers/pins.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
42
src/main/data/db/schemas/pin.ts
Normal file
42
src/main/data/db/schemas/pin.ts
Normal 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
|
||||
164
src/main/data/services/GroupService.ts
Normal file
164
src/main/data/services/GroupService.ts
Normal 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()
|
||||
193
src/main/data/services/PinService.ts
Normal file
193
src/main/data/services/PinService.ts
Normal 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()
|
||||
208
src/main/data/services/__tests__/GroupService.test.ts
Normal file
208
src/main/data/services/__tests__/GroupService.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
241
src/main/data/services/__tests__/PinService.test.ts
Normal file
241
src/main/data/services/__tests__/PinService.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user