feat: uuid ids everywhere, pickers for linked tickets, @pi/ui checkboxes
- Postgres: every table has a uuid `id` (gen_random_uuid()); the event store keeps its relay order in `seq`, and the lookup tables their uniqueness in constraints of their own. - MongoDB: `_id` is Mongo's ObjectId; `id` is the domain id (the same uuid as the aggregate's stream in Postgres), unique, and every query goes by it. Production starts from empty databases. - Web: linking tickets and hanging one from a parent are picked from a searchable select (links across projects, parents within the project) instead of typing a key; every checkbox is @pi/ui's pi-checkbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,11 +15,16 @@ import {
|
||||
SprintReadModel,
|
||||
implicitRank,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Schema } from 'mongoose';
|
||||
import { Schema, Types } from 'mongoose';
|
||||
|
||||
/* Read model (MongoDB): denormalised documents shaped for the GraphQL queries. */
|
||||
|
||||
type Doc<T extends { id: string }> = Omit<T, 'id'> & { _id: string };
|
||||
/**
|
||||
* Each document keeps the domain id (a uuid, the same one the event store
|
||||
* uses) in `id`, unique; `_id` is Mongo's own ObjectId and means nothing
|
||||
* outside the database.
|
||||
*/
|
||||
type Doc<T extends { id: string }> = T & { _id?: Types.ObjectId };
|
||||
|
||||
export type ProjectDoc = Doc<ProjectReadModel> & { memberIds: string[] };
|
||||
/** Links are split: outward ones come from the issue itself, inward ones from other issues' events. */
|
||||
@@ -34,11 +39,11 @@ export type ActivityDoc = Doc<ActivityReadModel>;
|
||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||
export type OutgoingWebhookDoc = Omit<OutgoingWebhook, 'id'> & { _id: string };
|
||||
export type AutomationDoc = Omit<Automation, 'id'> & { _id: string };
|
||||
export type OutgoingWebhookDoc = Doc<OutgoingWebhook>;
|
||||
export type AutomationDoc = Doc<Automation>;
|
||||
export interface AutomationRunDoc {
|
||||
/** `ruleId:eventKey`: a rule runs once per event. */
|
||||
_id: string;
|
||||
id: string;
|
||||
at: Date;
|
||||
}
|
||||
export interface GitRepositoryDoc {
|
||||
@@ -52,13 +57,15 @@ export interface GitRepositoryDoc {
|
||||
* kept: it reads as a repository named "principal" until the list is saved.
|
||||
*/
|
||||
export interface GitIntegrationDoc {
|
||||
_id: string;
|
||||
/** The project's id. */
|
||||
id: string;
|
||||
repositories?: GitRepositoryDoc[];
|
||||
secret?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
export interface NotificationSettingsDoc {
|
||||
_id: string;
|
||||
/** The user's id. */
|
||||
id: string;
|
||||
email: boolean;
|
||||
}
|
||||
|
||||
@@ -88,11 +95,13 @@ const link = {
|
||||
issueId: String,
|
||||
issueKey: String,
|
||||
};
|
||||
const loose = { strict: false, versionKey: false, _id: false } as const;
|
||||
const loose = { strict: false, versionKey: false } as const;
|
||||
/** The domain id: what every query looks a document up by. */
|
||||
const domainId = { type: String, required: true, unique: true };
|
||||
|
||||
export const ProjectSchema = new Schema<ProjectDoc>(
|
||||
{
|
||||
_id: String,
|
||||
id: domainId,
|
||||
key: { type: String, unique: true },
|
||||
isPublic: { type: Boolean, index: true },
|
||||
memberIds: { type: [String], index: true },
|
||||
@@ -105,7 +114,7 @@ export const ProjectSchema = new Schema<ProjectDoc>(
|
||||
|
||||
export const IssueSchema = new Schema<IssueDoc>(
|
||||
{
|
||||
_id: String,
|
||||
id: domainId,
|
||||
projectId: { type: String, index: true },
|
||||
key: { type: String, unique: true },
|
||||
status: String,
|
||||
@@ -149,7 +158,7 @@ IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
||||
|
||||
export const CommentSchema = new Schema<CommentDoc>(
|
||||
{
|
||||
_id: String,
|
||||
id: domainId,
|
||||
issueId: { type: String, index: true },
|
||||
replyTo: { type: String, index: true },
|
||||
author: userRef,
|
||||
@@ -160,13 +169,13 @@ export const CommentSchema = new Schema<CommentDoc>(
|
||||
CommentSchema.index({ issueId: 1, createdAt: 1 });
|
||||
|
||||
export const ReleaseSchema = new Schema<ReleaseDoc>(
|
||||
{ _id: String, projectId: { type: String, index: true }, version: Number },
|
||||
{ id: domainId, projectId: { type: String, index: true }, version: Number },
|
||||
{ ...loose, collection: 'releases_view' },
|
||||
);
|
||||
|
||||
export const SprintSchema = new Schema<SprintDoc>(
|
||||
{
|
||||
_id: String,
|
||||
id: domainId,
|
||||
projectId: { type: String, index: true },
|
||||
status: String,
|
||||
version: Number,
|
||||
@@ -176,7 +185,7 @@ export const SprintSchema = new Schema<SprintDoc>(
|
||||
|
||||
export const ActivitySchema = new Schema<ActivityDoc>(
|
||||
{
|
||||
_id: String,
|
||||
id: domainId,
|
||||
projectId: String,
|
||||
issueId: String,
|
||||
actor: userRef,
|
||||
@@ -188,14 +197,14 @@ ActivitySchema.index({ issueId: 1, occurredAt: -1 });
|
||||
|
||||
/** Every status and release step of every ticket (statistics). */
|
||||
export const TimelineSchema = new Schema<TimelineDoc>(
|
||||
{ _id: String, projectId: String, issueId: String, at: String },
|
||||
{ id: domainId, projectId: String, issueId: String, at: String },
|
||||
{ ...loose, collection: 'issue_timeline' },
|
||||
);
|
||||
TimelineSchema.index({ projectId: 1, at: 1 });
|
||||
|
||||
export const NotificationSchema = new Schema<NotificationDoc>(
|
||||
{
|
||||
_id: String,
|
||||
id: domainId,
|
||||
userId: String,
|
||||
readAt: String,
|
||||
createdAt: String,
|
||||
@@ -207,13 +216,13 @@ NotificationSchema.index({ userId: 1, createdAt: -1 });
|
||||
NotificationSchema.index({ userId: 1, readAt: 1 });
|
||||
|
||||
export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
|
||||
{ _id: String, email: Boolean },
|
||||
{ id: domainId, email: Boolean },
|
||||
{ ...loose, collection: 'notification_settings' },
|
||||
);
|
||||
|
||||
export const SavedFilterSchema = new Schema<SavedFilterDoc>(
|
||||
{
|
||||
_id: String,
|
||||
id: domainId,
|
||||
projectId: String,
|
||||
owner: userRef,
|
||||
name: String,
|
||||
@@ -226,13 +235,13 @@ SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
|
||||
SavedFilterSchema.index({ projectId: 1, shared: 1 });
|
||||
|
||||
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
|
||||
{ _id: String, repositories: [Object], secret: String, createdAt: String },
|
||||
{ id: domainId, repositories: [Object], secret: String, createdAt: String },
|
||||
{ ...loose, collection: 'git_integrations' },
|
||||
);
|
||||
|
||||
export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
|
||||
{
|
||||
_id: String,
|
||||
id: domainId,
|
||||
projectId: { type: String, index: true },
|
||||
active: Boolean,
|
||||
events: [String],
|
||||
@@ -244,7 +253,7 @@ export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
|
||||
|
||||
export const AutomationSchema = new Schema<AutomationDoc>(
|
||||
{
|
||||
_id: String,
|
||||
id: domainId,
|
||||
projectId: { type: String, index: true },
|
||||
active: Boolean,
|
||||
createdBy: userRef,
|
||||
@@ -254,7 +263,7 @@ export const AutomationSchema = new Schema<AutomationDoc>(
|
||||
);
|
||||
|
||||
export const AutomationRunSchema = new Schema<AutomationRunDoc>(
|
||||
{ _id: String, at: Date },
|
||||
{ id: domainId, at: Date },
|
||||
{ ...loose, collection: 'automation_runs' },
|
||||
);
|
||||
// Redeliveries happen within minutes; a month of memory is plenty.
|
||||
@@ -277,17 +286,17 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
||||
];
|
||||
|
||||
export function fromDoc<T extends { _id: string }>(
|
||||
doc: T,
|
||||
): Omit<T, '_id'> & { id: string } {
|
||||
const { _id, ...rest } = doc;
|
||||
return { id: _id, ...rest };
|
||||
/** A document as the model it holds: Mongo's own `_id` left out. */
|
||||
export function fromDoc<T extends { id: string }>(
|
||||
doc: T & { _id?: unknown },
|
||||
): T {
|
||||
const { _id: _ignored, ...rest } = doc;
|
||||
return rest as T;
|
||||
}
|
||||
|
||||
export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
const { _id, outwardLinks, inwardLinks, ...rest } = doc;
|
||||
const { _id: _ignored, outwardLinks, inwardLinks, ...rest } = doc;
|
||||
return {
|
||||
id: _id,
|
||||
...rest,
|
||||
links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])],
|
||||
// Tickets projected before attachments existed have none.
|
||||
|
||||
Reference in New Issue
Block a user