pg todo 2

This commit is contained in:
ordinarthur 2026-04-16 10:46:57 +02:00
parent f851da4677
commit 32f3105bef
22 changed files with 623 additions and 21 deletions

View File

@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(pnpm --filter @ordinarthur-os/db build)",
"Bash(pnpm --filter @ordinarthur-os/db tsc --noEmit)",
"Bash(pnpm --filter @ordinarthur-os/api add tsconfig-paths)",
"Read(//private/tmp/test_ts/pkg/**)",
"Bash(node -e \"const m = require\\('./a.ts'\\); console.log\\('CJS require result:', m\\);\")",
"Bash(pnpm --filter @ordinarthur-os/db migrate)"
]
}
}

View File

@ -19,6 +19,7 @@
"postgres": "^3.4.5", "postgres": "^3.4.5",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"tsconfig-paths": "^4.2.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,9 +1,14 @@
import { z } from "zod"; import { z } from "zod";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
// Charge apps/api/.env (si présent) avant de valider process.env.
// Utilise l'API native Node 20.12+ — pas de dépendance `dotenv`.
const envPath = path.resolve(__dirname, "..", "..", ".env");
if (existsSync(envPath)) {
process.loadEnvFile(envPath);
}
const EnvSchema = z.object({ const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"), NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().int().positive().default(3000), PORT: z.coerce.number().int().positive().default(3000),

View File

@ -1,4 +1,5 @@
import "reflect-metadata"; import "reflect-metadata";
import "tsconfig-paths/register";
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { Logger } from "@nestjs/common"; import { Logger } from "@nestjs/common";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";

View File

@ -1,7 +1,7 @@
import { Injectable, NestMiddleware, UnauthorizedException, Inject } from "@nestjs/common"; import { Injectable, NestMiddleware, UnauthorizedException, Inject } from "@nestjs/common";
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import { APP_CONFIG } from "../../config/config.module"; import { APP_CONFIG } from "@/config/config.module";
import type { AppConfig } from "../../config/env"; import type { AppConfig } from "@/config/env";
@Injectable() @Injectable()
export class BearerMiddleware implements NestMiddleware { export class BearerMiddleware implements NestMiddleware {

View File

@ -16,7 +16,7 @@ import {
JobPatchDto, JobPatchDto,
JobSearchCriteriaUpsert, JobSearchCriteriaUpsert,
} from "@ordinarthur-os/shared"; } from "@ordinarthur-os/shared";
import { ZodPipe } from "../../lib/zod-pipe"; import { ZodPipe } from "@/lib/zod-pipe";
import { JobsService } from "./jobs.service"; import { JobsService } from "./jobs.service";
@Controller("jobs") @Controller("jobs")

View File

@ -10,7 +10,7 @@ import {
JobSearchCriteriaUpsert, JobSearchCriteriaUpsert,
} from "@ordinarthur-os/shared"; } from "@ordinarthur-os/shared";
import { and, arrayOverlaps, asc, desc, eq, gte, lt, sql } from "drizzle-orm"; import { and, arrayOverlaps, asc, desc, eq, gte, lt, sql } from "drizzle-orm";
import { InjectDb, type Db } from "../../db/db.module"; import { InjectDb, type Db } from "@/db/db.module";
const { jobs, jobSearchCriteria } = schema; const { jobs, jobSearchCriteria } = schema;
const RETENTION_DAYS = 30; const RETENTION_DAYS = 30;

View File

@ -0,0 +1,60 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import {
TodoAiEnrichApplyRequest,
TodoCreateDto,
TodoListQuery,
TodoPatchDto,
} from "@ordinarthur-os/shared";
import { ZodPipe } from "@/lib/zod-pipe";
import { TodosService } from "./todos.service";
@Controller("todos")
export class TodosController {
constructor(private readonly todos: TodosService) {}
@Get()
list(@Query(new ZodPipe(TodoListQuery)) query: TodoListQuery) {
return this.todos.list(query);
}
@Post()
create(@Body(new ZodPipe(TodoCreateDto)) input: TodoCreateDto) {
return this.todos.create(input);
}
@Patch(":id")
patch(
@Param("id", new ParseUUIDPipe()) id: string,
@Body(new ZodPipe(TodoPatchDto)) patch: TodoPatchDto,
) {
return this.todos.patch(id, patch);
}
@Delete(":id")
remove(@Param("id", new ParseUUIDPipe()) id: string) {
return this.todos.remove(id);
}
@Post(":id/ai-enrich")
aiEnrich(@Param("id", new ParseUUIDPipe()) id: string) {
return this.todos.aiEnrich(id);
}
@Post(":id/ai-enrich/apply")
applyAiEnrich(
@Param("id", new ParseUUIDPipe()) id: string,
@Body(new ZodPipe(TodoAiEnrichApplyRequest)) body: TodoAiEnrichApplyRequest,
) {
return this.todos.applyAiEnrich(id, body.draft);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { TodosController } from "./todos.controller";
import { TodosService } from "./todos.service";
@Module({
controllers: [TodosController],
providers: [TodosService],
})
export class TodosModule {}

View File

@ -0,0 +1,209 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { schema } from "@ordinarthur-os/db";
import {
Todo,
TodoAiEnrichDraft,
TodoAiEnrichResponse,
TodoCreateDto,
TodoListQuery,
TodoPatchDto,
TodoPriority,
} from "@ordinarthur-os/shared";
import { and, arrayOverlaps, desc, eq, ilike, ne, sql } from "drizzle-orm";
import { InjectDb, type Db } from "@/db/db.module";
const { todos } = schema;
@Injectable()
export class TodosService {
constructor(@InjectDb() private readonly db: Db) {}
async list(query: TodoListQuery): Promise<Todo[]> {
const conditions = [];
if (query.archived === true) {
conditions.push(eq(todos.status, "archived"));
} else if (!query.status) {
conditions.push(ne(todos.status, "archived"));
}
if (query.status) conditions.push(eq(todos.status, query.status));
if (typeof query.priority === "number")
conditions.push(eq(todos.priority, query.priority));
if (query.context) conditions.push(ilike(todos.context, `%${query.context}%`));
if (query.project_id) conditions.push(eq(todos.projectId, query.project_id));
if (query.tags?.length) conditions.push(arrayOverlaps(todos.tags, query.tags));
const rows = await this.db
.select()
.from(todos)
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(desc(todos.createdAt))
.limit(500);
return rows.map(rowToTodo);
}
async create(input: TodoCreateDto): Promise<Todo> {
const rows = await this.db
.insert(todos)
.values(dtoToInsert(input))
.returning();
const row = rows[0];
if (!row) throw new Error("Insert returned no row");
return rowToTodo(row);
}
async patch(id: string, patch: TodoPatchDto): Promise<Todo> {
const existing = await this.getById(id);
const values = dtoToInsert({ ...existing, ...patch } as TodoCreateDto);
// completed_at : si le patch le précise explicitement, on l'honore ;
// sinon on le dérive du status.
let completedAt: Date | null | undefined = undefined;
if ("completed_at" in patch && patch.completed_at !== undefined) {
completedAt = patch.completed_at ? new Date(patch.completed_at) : null;
} else if (patch.status === "done") {
completedAt = new Date();
} else if (patch.status) {
completedAt = null;
}
const [row] = await this.db
.update(todos)
.set({
...values,
...(completedAt !== undefined ? { completedAt } : {}),
...(patch.ai_enriched !== undefined ? { aiEnriched: patch.ai_enriched } : {}),
})
.where(eq(todos.id, id))
.returning();
if (!row) throw new NotFoundException(`Todo ${id} not found`);
return rowToTodo(row);
}
async remove(id: string): Promise<void> {
await this.db.delete(todos).where(eq(todos.id, id));
}
async aiEnrich(id: string): Promise<TodoAiEnrichResponse> {
const todo = await this.getById(id);
const draft = buildEnrichmentDraft(todo);
return { todo_id: todo.id, draft };
}
async applyAiEnrich(id: string, draft: TodoAiEnrichDraft): Promise<Todo> {
const todo = await this.getById(id);
return this.patch(id, {
description: draft.description ?? todo.description,
priority: draft.priority ?? todo.priority,
tags: draft.tags ?? todo.tags,
context: draft.context ?? todo.context,
energy: draft.energy ?? todo.energy,
verification_steps: draft.verification_steps ?? todo.verification_steps,
ai_enriched: true,
});
}
private async getById(id: string): Promise<Todo> {
const [row] = await this.db.select().from(todos).where(eq(todos.id, id));
if (!row) throw new NotFoundException(`Todo ${id} not found`);
return rowToTodo(row);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function rowToTodo(row: typeof todos.$inferSelect): Todo {
return {
id: row.id,
title: row.title,
description: row.description ?? null,
status: row.status as Todo["status"],
priority: row.priority as TodoPriority | null,
due_at: row.dueAt?.toISOString() ?? null,
tags: row.tags ?? [],
project_id: row.projectId ?? null,
checklist: (row.checklist as Todo["checklist"]) ?? [],
energy: row.energy as Todo["energy"] ?? null,
context: row.context ?? null,
recurrence: row.recurrence ?? null,
ticket_url: row.ticketUrl ?? null,
verification_steps: row.verificationSteps ?? [],
ai_enriched: row.aiEnriched,
created_at: row.createdAt.toISOString(),
completed_at: row.completedAt?.toISOString() ?? null,
};
}
function dtoToInsert(input: TodoCreateDto) {
return {
title: input.title,
description: input.description ?? null,
status: input.status ?? "inbox",
priority: input.priority ?? null,
dueAt: input.due_at ? new Date(input.due_at) : null,
tags: input.tags ?? [],
projectId: input.project_id ?? null,
checklist: input.checklist ?? [],
energy: input.energy ?? null,
context: input.context ?? null,
recurrence: input.recurrence ?? null,
ticketUrl: input.ticket_url ?? null,
verificationSteps: input.verification_steps ?? [],
};
}
// ---------------------------------------------------------------------------
// Enrichissement IA heuristique (remplacé par Mistral en Phase 5)
// ---------------------------------------------------------------------------
function buildEnrichmentDraft(todo: Todo): TodoAiEnrichDraft {
const title = todo.title.toLowerCase();
const tags = inferTags(todo.title, todo.tags);
if (title.includes("appeler") || title.includes("call")) {
return {
description: todo.description ?? "Préparer les points, lancer l'appel, noter le prochain pas.",
priority: todo.priority ?? 2,
tags,
context: todo.context ?? "@phone",
energy: todo.energy ?? "med",
};
}
if (title.includes("mail") || title.includes("email") || title.includes("répondre")) {
return {
description: todo.description ?? "Rédiger, vérifier les PJ et envoyer.",
priority: todo.priority ?? 1,
tags,
context: todo.context ?? "@laptop",
energy: todo.energy ?? "low",
};
}
return {
description: todo.description ?? "Découper en une première action concrète et avancer.",
priority: todo.priority ?? 1,
tags,
context: todo.context ?? "@focus",
energy: todo.energy ?? "med",
};
}
function inferTags(title: string, currentTags: string[]) {
const base = new Set(currentTags.map((t) => t.trim()).filter(Boolean));
const tokens = title
.toLowerCase()
.split(/[^a-z0-9àâçéèêëîïôûùüÿñæœ]+/i)
.filter((t) => t.length >= 4)
.filter((t) => !STOPWORDS.has(t))
.slice(0, 4);
for (const token of tokens) base.add(token);
return Array.from(base);
}
const STOPWORDS = new Set(["avec", "dans", "pour", "faire", "plus", "arthur", "todo", "that", "this", "from"]);

View File

@ -8,6 +8,10 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"verbatimModuleSyntax": false, "verbatimModuleSyntax": false,
"isolatedModules": false "isolatedModules": false
}, },

View File

@ -0,0 +1,221 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Todo, TodoCreateDto, TodoPatchDto, TodoStatus } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { BigHeading, Label, SectionHeader } from "@/design";
export const Route = createFileRoute("/todos")({ component: TodosPage });
function TodosPage() {
const queryClient = useQueryClient();
const [input, setInput] = useState("");
const [showDone, setShowDone] = useState(false);
const todos = useQuery({
queryKey: ["todos", showDone],
queryFn: () =>
api<Todo[]>(showDone ? "/todos?status=done" : "/todos"),
});
const createTodo = useMutation({
mutationFn: (payload: TodoCreateDto) =>
api<Todo>("/todos", { method: "POST", body: JSON.stringify(payload) }),
onSuccess: () => {
setInput("");
void queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
const patchTodo = useMutation({
mutationFn: ({ id, patch }: { id: string; patch: TodoPatchDto }) =>
api<Todo>(`/todos/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
const deleteTodo = useMutation({
mutationFn: (id: string) => api<void>(`/todos/${id}`, { method: "DELETE" }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const title = input.trim();
if (!title) return;
createTodo.mutate({ title, status: "inbox", tags: [], checklist: [], verification_steps: [] });
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as unknown as React.FormEvent);
}
}
const data = todos.data ?? [];
const active = data.filter((t) => t.status !== "done" && t.status !== "archived");
const done = data.filter((t) => t.status === "done");
return (
<div className="space-y-10">
<section className="space-y-4">
<Label prefix="[ 02 ]">PHASE 2 · TODOS</Label>
<BigHeading>
Capture <em>rapide</em>, traitement via le magic button.
</BigHeading>
<p className="max-w-2xl font-sans text-sm leading-6 text-ink">
Écris une tâche, appuie sur Entrée. Le reste (priorité, contexte, tags) sera rempli par
le bouton vocal en Phase 5.
</p>
</section>
<SectionHeader number="01" label="CAPTURE" title="Ajoute une tâche" />
<form onSubmit={handleSubmit} className="border border-ink">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Relancer Paul… (Entrée pour valider, Shift+Entrée pour sauter une ligne)"
rows={3}
className="w-full bg-transparent px-4 py-3 font-sans text-sm text-ink outline-none placeholder:text-muted resize-none"
/>
<div className="border-t border-ink px-4 py-2 flex justify-end">
<button
type="submit"
disabled={!input.trim() || createTodo.isPending}
className="border border-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg disabled:opacity-40"
>
Ajouter
</button>
</div>
</form>
<SectionHeader
number="02"
label="LISTE"
title={todos.isLoading ? "Chargement…" : `${active.length} tâche${active.length !== 1 ? "s" : ""} en cours`}
/>
<div className="border border-ink divide-y divide-ink">
{todos.isLoading ? (
<EmptyState text="Chargement…" />
) : todos.isError ? (
<EmptyState text="Impossible de charger les todos. Vérifie l'API." />
) : active.length === 0 ? (
<EmptyState text="Aucune tâche en cours. Capture la prochaine ci-dessus." />
) : (
active.map((todo) => (
<TodoRow
key={todo.id}
todo={todo}
onToggleDone={() =>
patchTodo.mutate({
id: todo.id,
patch: { status: "done" },
})
}
onDelete={() => deleteTodo.mutate(todo.id)}
/>
))
)}
</div>
{/* Section "faites" collapsible */}
{(done.length > 0 || showDone) && (
<>
<button
type="button"
onClick={() => setShowDone((v) => !v)}
className="font-mono text-[11px] uppercase tracking-label text-ink border border-ink px-4 py-2 hover:bg-ink hover:text-bg transition-colors"
>
{showDone ? "Masquer les faites" : `Voir les faites (${done.length})`}
</button>
{showDone && (
<div className="border border-ink divide-y divide-ink opacity-60">
{done.map((todo) => (
<TodoRow
key={todo.id}
todo={todo}
onToggleDone={() =>
patchTodo.mutate({ id: todo.id, patch: { status: "inbox" } })
}
onDelete={() => deleteTodo.mutate(todo.id)}
isDone
/>
))}
</div>
)}
</>
)}
</div>
);
}
function TodoRow({
todo,
onToggleDone,
onDelete,
isDone = false,
}: {
todo: Todo;
onToggleDone: () => void;
onDelete: () => void;
isDone?: boolean;
}) {
const [title, setTitle] = useState(todo.title);
return (
<article className="flex items-start gap-3 px-4 py-3">
{/* Checkbox */}
<button
type="button"
onClick={onToggleDone}
aria-label={isDone ? "Rouvrir" : "Marquer comme fait"}
className={[
"mt-0.5 flex-shrink-0 w-4 h-4 border border-ink transition-colors",
isDone ? "bg-ink" : "hover:bg-ink/20",
].join(" ")}
/>
{/* Titre éditable */}
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={(e) => {
const next = e.target.value.trim();
if (next && next !== todo.title) {
// patch silencieux via fetch direct pour ne pas invalider inutilement
void api<Todo>(`/todos/${todo.id}`, {
method: "PATCH",
body: JSON.stringify({ title: next } satisfies TodoPatchDto),
});
}
}}
className={[
"flex-1 min-w-0 bg-transparent font-sans text-sm text-ink outline-none border-b border-transparent focus:border-ink",
isDone ? "line-through text-muted" : "",
].join(" ")}
/>
{/* Supprimer */}
<button
type="button"
onClick={onDelete}
aria-label="Supprimer"
className="flex-shrink-0 font-mono text-[10px] uppercase tracking-label text-muted hover:text-ink transition-colors px-1"
>
×
</button>
</article>
);
}
function EmptyState({ text }: { text: string }) {
return (
<div className="px-4 py-8">
<p className="font-sans text-sm text-muted">{text}</p>
</div>
);
}

View File

@ -0,0 +1,27 @@
-- 0002_todos.sql — Phase 2
set search_path to ordinarthur_os, public;
create table if not exists ordinarthur_os.todos (
id uuid primary key default gen_random_uuid(),
title text not null,
description text,
status text not null default 'inbox'
check (status in ('inbox', 'todo', 'doing', 'done', 'archived')),
priority smallint check (priority between 0 and 3),
due_at timestamptz,
tags text[] not null default '{}',
project_id uuid,
checklist jsonb not null default '[]'::jsonb,
energy text check (energy in ('low', 'med', 'high')),
context text,
recurrence text,
ticket_url text,
verification_steps text[] not null default '{}',
ai_enriched boolean not null default false,
created_at timestamptz not null default now(),
completed_at timestamptz
);
create index if not exists todos_status_idx on ordinarthur_os.todos(status);
create index if not exists todos_due_at_idx on ordinarthur_os.todos(due_at);
create index if not exists todos_tags_gin on ordinarthur_os.todos using gin (tags);

View File

@ -15,6 +15,13 @@
"when": 1744848060000, "when": 1744848060000,
"tag": "0001_jobs", "tag": "0001_jobs",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1744934400000,
"tag": "0002_todos",
"breakpoints": true
} }
] ]
} }

View File

@ -2,16 +2,19 @@
"name": "@ordinarthur-os/db", "name": "@ordinarthur-os/db",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "main": "./dist/index.js",
"main": "./src/index.ts", "types": "./dist/index.d.ts",
"types": "./src/index.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": {
"./schema": "./src/schema/index.ts" "types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"generate": "drizzle-kit generate", "generate": "drizzle-kit generate",
"migrate": "tsx src/migrate.ts", "migrate": "tsx --env-file=../../apps/api/.env src/migrate.ts",
"studio": "drizzle-kit studio", "studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },

View File

@ -1,3 +1,3 @@
export * as schema from "./schema"; export * as schema from "./schema/index";
export { appSchema } from "./schema"; export { appSchema } from "./schema/index";
export { createDb, type Db, type DbHandle } from "./client"; export { createDb, type Db, type DbHandle } from "./client";

View File

@ -3,6 +3,7 @@
* *
* DATABASE_URL=postgres://... pnpm --filter @ordinarthur-os/db migrate * DATABASE_URL=postgres://... pnpm --filter @ordinarthur-os/db migrate
*/ */
import * as path from "node:path";
import { migrate } from "drizzle-orm/postgres-js/migrator"; import { migrate } from "drizzle-orm/postgres-js/migrator";
import { createDb } from "./client"; import { createDb } from "./client";
@ -16,7 +17,7 @@ async function main() {
const { db, close } = createDb(url); const { db, close } = createDb(url);
try { try {
console.log("[migrate] application des migrations…"); console.log("[migrate] application des migrations…");
await migrate(db, { migrationsFolder: new URL("../migrations", import.meta.url).pathname }); await migrate(db, { migrationsFolder: path.resolve(__dirname, "..", "migrations") });
console.log("[migrate] ✓ à jour"); console.log("[migrate] ✓ à jour");
} finally { } finally {
await close(); await close();

View File

@ -1,2 +1,3 @@
export { appSchema } from "./_schema"; export { appSchema } from "./_schema";
export * from "./jobs"; export * from "./jobs";
export * from "./todos";

View File

@ -0,0 +1,34 @@
import { sql } from "drizzle-orm";
import { boolean, index, jsonb, smallint, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { appSchema } from "./_schema";
export const todos = appSchema.table(
"todos",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
title: text("title").notNull(),
description: text("description"),
status: text("status").notNull().default("inbox"),
priority: smallint("priority"),
dueAt: timestamp("due_at", { withTimezone: true }),
tags: text("tags").array().notNull().default(sql`'{}'::text[]`),
projectId: uuid("project_id"),
checklist: jsonb("checklist").notNull().default(sql`'[]'::jsonb`),
energy: text("energy"),
context: text("context"),
recurrence: text("recurrence"),
ticketUrl: text("ticket_url"),
verificationSteps: text("verification_steps").array().notNull().default(sql`'{}'::text[]`),
aiEnriched: boolean("ai_enriched").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp("completed_at", { withTimezone: true }),
},
(t) => ({
statusIdx: index("todos_status_idx").on(t.status),
dueAtIdx: index("todos_due_at_idx").on(t.dueAt),
tagsGin: index("todos_tags_gin").using("gin", t.tags),
}),
);
export type TodoRow = typeof todos.$inferSelect;
export type TodoInsert = typeof todos.$inferInsert;

View File

@ -1,12 +1,15 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"module": "ESNext", "module": "CommonJS",
"moduleResolution": "Bundler", "moduleResolution": "Node",
"target": "ES2022", "target": "ES2022",
"outDir": "dist", "outDir": "dist",
"rootDir": "src",
"declaration": true, "declaration": true,
"noEmit": true "declarationMap": true,
"sourceMap": true,
"verbatimModuleSyntax": false
}, },
"include": ["src", "drizzle.config.ts"] "include": ["src"]
} }

3
pnpm-lock.yaml generated
View File

@ -44,6 +44,9 @@ importers:
rxjs: rxjs:
specifier: ^7.8.1 specifier: ^7.8.1
version: 7.8.2 version: 7.8.2
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
zod: zod:
specifier: ^3.23.8 specifier: ^3.23.8
version: 3.25.76 version: 3.25.76

View File

@ -7,7 +7,8 @@
}, },
"dev": { "dev": {
"cache": false, "cache": false,
"persistent": true "persistent": true,
"dependsOn": ["^build"]
}, },
"lint": {}, "lint": {},
"typecheck": { "typecheck": {