@hasna/todos 0.11.87 → 0.11.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/cloud-router.d.ts +13 -3
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/agent-commands.d.ts.map +1 -1
- package/dist/cli/commands/project-commands.d.ts.map +1 -1
- package/dist/cli/commands/task-commands.d.ts.map +1 -1
- package/dist/cli/index.js +1008 -125
- package/dist/contracts.js +301 -72
- package/dist/db/projects.d.ts +2 -2
- package/dist/db/projects.d.ts.map +1 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/slug-claims.d.ts +11 -0
- package/dist/db/slug-claims.d.ts.map +1 -0
- package/dist/db/task-lists.d.ts.map +1 -1
- package/dist/index.js +641 -100
- package/dist/lib/slugs.d.ts +19 -0
- package/dist/lib/slugs.d.ts.map +1 -0
- package/dist/mcp/index.js +897 -102
- package/dist/registry.js +301 -72
- package/dist/release-provenance.json +3 -3
- package/dist/sdk/index.js +56 -0
- package/dist/sdk/v1.generated.d.ts +79 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +2 -0
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +906 -108
- package/dist/server/openapi.d.ts +464 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +3 -2
- package/dist/storage/interfaces.d.ts.map +1 -1
- package/dist/storage/local-sqlite.d.ts.map +1 -1
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/postgres-sync.d.ts +30 -0
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage/shadow.d.ts.map +1 -1
- package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +635 -88
- package/dist/types/index.d.ts +19 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -8783,6 +8783,7 @@ var init_redaction = __esm(() => {
|
|
|
8783
8783
|
});
|
|
8784
8784
|
|
|
8785
8785
|
// src/cli/cloud-router.ts
|
|
8786
|
+
import { resolve as resolvePath } from "path";
|
|
8786
8787
|
function getTodosCloudClient(env = process.env) {
|
|
8787
8788
|
if (_cache !== undefined)
|
|
8788
8789
|
return _cache.client;
|
|
@@ -8866,6 +8867,39 @@ async function cloudListProjects(client) {
|
|
|
8866
8867
|
const envelope = res.raw;
|
|
8867
8868
|
return Array.isArray(envelope?.projects) ? envelope.projects : res.items;
|
|
8868
8869
|
}
|
|
8870
|
+
function cloudProjectSlug(value) {
|
|
8871
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
8872
|
+
}
|
|
8873
|
+
function cloudProjectPathBasename(value) {
|
|
8874
|
+
return value.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? value;
|
|
8875
|
+
}
|
|
8876
|
+
function uniqueProjectMatches(projects, predicate) {
|
|
8877
|
+
return [...new Map(projects.filter(predicate).map((project) => [project.id, project])).values()];
|
|
8878
|
+
}
|
|
8879
|
+
function resolveCloudProjectRef(projects, ref) {
|
|
8880
|
+
const input = ref.trim();
|
|
8881
|
+
const normalizedRef = input.toLowerCase();
|
|
8882
|
+
const pathLike = input.startsWith(".") || input.includes("/") || input.includes("\\");
|
|
8883
|
+
const normalizedPath = pathLike ? resolvePath(input) : undefined;
|
|
8884
|
+
const slug = cloudProjectSlug(pathLike ? cloudProjectPathBasename(input) : input);
|
|
8885
|
+
const matchGroups = [
|
|
8886
|
+
uniqueProjectMatches(projects, (project) => project.id.toLowerCase() === normalizedRef),
|
|
8887
|
+
uniqueProjectMatches(projects, (project) => project.path === input || normalizedPath !== undefined && resolvePath(project.path) === normalizedPath),
|
|
8888
|
+
uniqueProjectMatches(projects, (project) => project.name.toLowerCase() === normalizedRef),
|
|
8889
|
+
uniqueProjectMatches(projects, (project) => project.task_list_id === input || cloudProjectSlug(project.name) === slug || cloudProjectSlug(cloudProjectPathBasename(project.path)) === slug),
|
|
8890
|
+
uniqueProjectMatches(projects, (project) => project.id.toLowerCase().startsWith(normalizedRef))
|
|
8891
|
+
];
|
|
8892
|
+
for (const matches of matchGroups) {
|
|
8893
|
+
if (matches.length === 1)
|
|
8894
|
+
return matches[0].id;
|
|
8895
|
+
if (matches.length > 1)
|
|
8896
|
+
throw new Error(`Project reference is ambiguous: "${input}"`);
|
|
8897
|
+
}
|
|
8898
|
+
throw new Error(`Project not found: "${input}"`);
|
|
8899
|
+
}
|
|
8900
|
+
async function cloudResolveProjectRef(client, ref) {
|
|
8901
|
+
return resolveCloudProjectRef(await cloudListProjects(client), ref);
|
|
8902
|
+
}
|
|
8869
8903
|
async function cloudListPlans(client, projectId) {
|
|
8870
8904
|
const query = projectId ? { project_id: projectId } : {};
|
|
8871
8905
|
const res = await client.list("plans", { query });
|
|
@@ -9168,12 +9202,30 @@ async function cloudListTaskLists(client, projectId) {
|
|
|
9168
9202
|
return Array.isArray(raw) ? raw : [];
|
|
9169
9203
|
}
|
|
9170
9204
|
async function cloudResolveTaskListRef(client, ref, projectId) {
|
|
9205
|
+
const input = ref.trim();
|
|
9206
|
+
const normalizedIdRef = input.toLowerCase();
|
|
9207
|
+
if (UUID_RE.test(input) && !projectId)
|
|
9208
|
+
return normalizedIdRef;
|
|
9171
9209
|
const lists = await cloudListTaskLists(client, projectId);
|
|
9172
|
-
const
|
|
9173
|
-
if (
|
|
9174
|
-
return
|
|
9175
|
-
|
|
9176
|
-
|
|
9210
|
+
const exactIds = lists.filter((list) => list.id.toLowerCase() === normalizedIdRef);
|
|
9211
|
+
if (exactIds.length === 1)
|
|
9212
|
+
return exactIds[0].id;
|
|
9213
|
+
if (exactIds.length > 1) {
|
|
9214
|
+
throw new Error(`Task list reference is ambiguous: "${input}"`);
|
|
9215
|
+
}
|
|
9216
|
+
const slugs = lists.filter((list) => list.slug === input);
|
|
9217
|
+
if (slugs.length === 1)
|
|
9218
|
+
return slugs[0].id;
|
|
9219
|
+
if (slugs.length > 1) {
|
|
9220
|
+
throw new Error(`Task list reference is ambiguous: "${input}"`);
|
|
9221
|
+
}
|
|
9222
|
+
const prefixes = lists.filter((list) => list.id.toLowerCase().startsWith(normalizedIdRef));
|
|
9223
|
+
if (prefixes.length === 1)
|
|
9224
|
+
return prefixes[0].id;
|
|
9225
|
+
if (prefixes.length > 1) {
|
|
9226
|
+
throw new Error(`Task list reference is ambiguous: "${input}"`);
|
|
9227
|
+
}
|
|
9228
|
+
throw new Error(`Task list not found: "${input}"`);
|
|
9177
9229
|
}
|
|
9178
9230
|
async function cloudCreateTaskList(client, input) {
|
|
9179
9231
|
const raw = await client.transport.post("/task-lists", input);
|
|
@@ -9182,6 +9234,13 @@ async function cloudCreateTaskList(client, input) {
|
|
|
9182
9234
|
}
|
|
9183
9235
|
return raw;
|
|
9184
9236
|
}
|
|
9237
|
+
async function cloudRenameProject(client, ref, newSlug, name) {
|
|
9238
|
+
const id = await cloudResolveProjectRef(client, ref);
|
|
9239
|
+
const normalizedSlug = cloudProjectSlug(newSlug);
|
|
9240
|
+
if (!normalizedSlug)
|
|
9241
|
+
throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
|
|
9242
|
+
return client.transport.post(`/projects/${encodeURIComponent(id)}/rename`, { new_slug: normalizedSlug, ...name !== undefined ? { name } : {} });
|
|
9243
|
+
}
|
|
9185
9244
|
async function cloudDeleteTaskList(client, id) {
|
|
9186
9245
|
await client.delete("task-lists", id);
|
|
9187
9246
|
return true;
|
|
@@ -9309,10 +9368,11 @@ async function cloudTimeline(client, options = {}) {
|
|
|
9309
9368
|
const limit = options.limit ?? 50;
|
|
9310
9369
|
return { entries: entries.slice(offset, offset + limit), total, limit, offset };
|
|
9311
9370
|
}
|
|
9312
|
-
var _cache, PRIORITY_RANK;
|
|
9371
|
+
var UUID_RE, _cache, PRIORITY_RANK;
|
|
9313
9372
|
var init_cloud_router = __esm(() => {
|
|
9314
9373
|
init_storage();
|
|
9315
9374
|
init_redaction();
|
|
9375
|
+
UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9316
9376
|
PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
9317
9377
|
});
|
|
9318
9378
|
|
|
@@ -11025,6 +11085,95 @@ function ensureSchema(db) {
|
|
|
11025
11085
|
)`);
|
|
11026
11086
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_project ON project_machine_paths(project_id)");
|
|
11027
11087
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_machine ON project_machine_paths(machine_id)");
|
|
11088
|
+
ensureTable("canonical_slug_claims", `
|
|
11089
|
+
CREATE TABLE canonical_slug_claims (
|
|
11090
|
+
kind TEXT NOT NULL CHECK(kind IN ('project', 'task_list')),
|
|
11091
|
+
scope_key TEXT NOT NULL,
|
|
11092
|
+
slug TEXT NOT NULL,
|
|
11093
|
+
object_id TEXT NOT NULL,
|
|
11094
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
11095
|
+
PRIMARY KEY (kind, scope_key, slug)
|
|
11096
|
+
)`);
|
|
11097
|
+
ensureIndex("CREATE INDEX IF NOT EXISTS idx_canonical_slug_claims_object ON canonical_slug_claims(kind, object_id)");
|
|
11098
|
+
ensureColumn("projects", "task_list_id", "TEXT");
|
|
11099
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_insert
|
|
11100
|
+
BEFORE INSERT ON projects
|
|
11101
|
+
WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
|
|
11102
|
+
BEGIN
|
|
11103
|
+
SELECT CASE WHEN EXISTS (
|
|
11104
|
+
SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
|
|
11105
|
+
) THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
11106
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
11107
|
+
VALUES ('project', 'global', NEW.task_list_id, NEW.id);
|
|
11108
|
+
SELECT CASE WHEN (
|
|
11109
|
+
SELECT object_id FROM canonical_slug_claims
|
|
11110
|
+
WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
|
|
11111
|
+
) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
11112
|
+
END`);
|
|
11113
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_update
|
|
11114
|
+
BEFORE UPDATE OF task_list_id ON projects
|
|
11115
|
+
WHEN NEW.task_list_id IS NOT OLD.task_list_id
|
|
11116
|
+
BEGIN
|
|
11117
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = NEW.id;
|
|
11118
|
+
SELECT CASE WHEN EXISTS (
|
|
11119
|
+
SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
|
|
11120
|
+
) AND NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
|
|
11121
|
+
THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
11122
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
11123
|
+
SELECT 'project', 'global', NEW.task_list_id, NEW.id
|
|
11124
|
+
WHERE NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '';
|
|
11125
|
+
SELECT CASE WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '' AND (
|
|
11126
|
+
SELECT object_id FROM canonical_slug_claims
|
|
11127
|
+
WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
|
|
11128
|
+
) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
11129
|
+
END`);
|
|
11130
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS release_project_canonical_slug_delete
|
|
11131
|
+
AFTER DELETE ON projects
|
|
11132
|
+
BEGIN
|
|
11133
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = OLD.id;
|
|
11134
|
+
END`);
|
|
11135
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_insert
|
|
11136
|
+
BEFORE INSERT ON task_lists
|
|
11137
|
+
WHEN NEW.slug IS NOT NULL AND NEW.slug <> ''
|
|
11138
|
+
BEGIN
|
|
11139
|
+
SELECT CASE WHEN EXISTS (
|
|
11140
|
+
SELECT 1 FROM task_lists
|
|
11141
|
+
WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
|
|
11142
|
+
) THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
11143
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
11144
|
+
VALUES ('task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id);
|
|
11145
|
+
SELECT CASE WHEN (
|
|
11146
|
+
SELECT object_id FROM canonical_slug_claims
|
|
11147
|
+
WHERE kind = 'task_list'
|
|
11148
|
+
AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
|
|
11149
|
+
AND slug = NEW.slug
|
|
11150
|
+
) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
11151
|
+
END`);
|
|
11152
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_update
|
|
11153
|
+
BEFORE UPDATE OF slug, project_id ON task_lists
|
|
11154
|
+
WHEN NEW.slug IS NOT OLD.slug OR NEW.project_id IS NOT OLD.project_id
|
|
11155
|
+
BEGIN
|
|
11156
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = NEW.id;
|
|
11157
|
+
SELECT CASE WHEN EXISTS (
|
|
11158
|
+
SELECT 1 FROM task_lists
|
|
11159
|
+
WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
|
|
11160
|
+
) AND NEW.slug IS NOT NULL AND NEW.slug <> ''
|
|
11161
|
+
THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
11162
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
11163
|
+
SELECT 'task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id
|
|
11164
|
+
WHERE NEW.slug IS NOT NULL AND NEW.slug <> '';
|
|
11165
|
+
SELECT CASE WHEN NEW.slug IS NOT NULL AND NEW.slug <> '' AND (
|
|
11166
|
+
SELECT object_id FROM canonical_slug_claims
|
|
11167
|
+
WHERE kind = 'task_list'
|
|
11168
|
+
AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
|
|
11169
|
+
AND slug = NEW.slug
|
|
11170
|
+
) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
11171
|
+
END`);
|
|
11172
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS release_task_list_canonical_slug_delete
|
|
11173
|
+
AFTER DELETE ON task_lists
|
|
11174
|
+
BEGIN
|
|
11175
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = OLD.id;
|
|
11176
|
+
END`);
|
|
11028
11177
|
ensureTable("storage_tombstones", `
|
|
11029
11178
|
CREATE TABLE storage_tombstones (
|
|
11030
11179
|
id TEXT PRIMARY KEY,
|
|
@@ -12438,7 +12587,7 @@ var init_database = __esm(() => {
|
|
|
12438
12587
|
});
|
|
12439
12588
|
|
|
12440
12589
|
// src/types/index.ts
|
|
12441
|
-
var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
|
|
12590
|
+
var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
|
|
12442
12591
|
var init_types = __esm(() => {
|
|
12443
12592
|
TASK_STATUSES = [
|
|
12444
12593
|
"pending",
|
|
@@ -12487,6 +12636,14 @@ var init_types = __esm(() => {
|
|
|
12487
12636
|
this.name = "ProjectNotFoundError";
|
|
12488
12637
|
}
|
|
12489
12638
|
};
|
|
12639
|
+
ResourceConflictError = class ResourceConflictError extends Error {
|
|
12640
|
+
code;
|
|
12641
|
+
constructor(code, message) {
|
|
12642
|
+
super(message);
|
|
12643
|
+
this.code = code;
|
|
12644
|
+
this.name = "ResourceConflictError";
|
|
12645
|
+
}
|
|
12646
|
+
};
|
|
12490
12647
|
PlanNotFoundError = class PlanNotFoundError extends Error {
|
|
12491
12648
|
planId;
|
|
12492
12649
|
static code = "PLAN_NOT_FOUND";
|
|
@@ -12639,6 +12796,91 @@ var init_storage_tombstones = __esm(() => {
|
|
|
12639
12796
|
init_machines();
|
|
12640
12797
|
});
|
|
12641
12798
|
|
|
12799
|
+
// src/lib/slugs.ts
|
|
12800
|
+
function normalizeSlug(value) {
|
|
12801
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
12802
|
+
}
|
|
12803
|
+
function isCanonicalSlug(value) {
|
|
12804
|
+
return typeof value === "string" && value.length > 0 && normalizeSlug(value) === value;
|
|
12805
|
+
}
|
|
12806
|
+
function isValidTaskListProjectScope(value) {
|
|
12807
|
+
return value === undefined || value === null || typeof value === "string" && value.trim().length > 0;
|
|
12808
|
+
}
|
|
12809
|
+
function validateSnapshotRoutingRecords(projects, taskLists) {
|
|
12810
|
+
const errors = [];
|
|
12811
|
+
const projectSlugs = new Map;
|
|
12812
|
+
const taskListSlugs = new Map;
|
|
12813
|
+
for (const project of projects) {
|
|
12814
|
+
if (!isCanonicalSlug(project.task_list_id)) {
|
|
12815
|
+
errors.push(`project ${project.id}: task_list_id must be non-empty canonical kebab-case`);
|
|
12816
|
+
continue;
|
|
12817
|
+
}
|
|
12818
|
+
const existing = projectSlugs.get(project.task_list_id);
|
|
12819
|
+
if (projectSlugs.has(project.task_list_id)) {
|
|
12820
|
+
errors.push(`project ${project.id}: task_list_id duplicates project ${existing}: ${project.task_list_id}`);
|
|
12821
|
+
} else {
|
|
12822
|
+
projectSlugs.set(project.task_list_id, project.id);
|
|
12823
|
+
}
|
|
12824
|
+
}
|
|
12825
|
+
for (const taskList of taskLists) {
|
|
12826
|
+
if (!isCanonicalSlug(taskList.slug)) {
|
|
12827
|
+
errors.push(`task list ${taskList.id}: slug must be non-empty canonical kebab-case`);
|
|
12828
|
+
continue;
|
|
12829
|
+
}
|
|
12830
|
+
if (!isValidTaskListProjectScope(taskList.project_id)) {
|
|
12831
|
+
errors.push(`task list ${taskList.id}: project_id must be null, missing, or a non-empty string`);
|
|
12832
|
+
continue;
|
|
12833
|
+
}
|
|
12834
|
+
const scope = taskList.project_id ?? null;
|
|
12835
|
+
const scopedSlugs = taskListSlugs.get(scope) ?? new Map;
|
|
12836
|
+
const existing = scopedSlugs.get(taskList.slug);
|
|
12837
|
+
if (scopedSlugs.has(taskList.slug)) {
|
|
12838
|
+
errors.push(`task list ${taskList.id}: slug duplicates task list ${existing} in the same scope: ${taskList.slug}`);
|
|
12839
|
+
} else {
|
|
12840
|
+
scopedSlugs.set(taskList.slug, taskList.id);
|
|
12841
|
+
taskListSlugs.set(scope, scopedSlugs);
|
|
12842
|
+
}
|
|
12843
|
+
}
|
|
12844
|
+
return errors;
|
|
12845
|
+
}
|
|
12846
|
+
function validateSnapshotRoutingDestinationConflicts(projects, taskLists, existingProjects, existingTaskLists) {
|
|
12847
|
+
const errors = [];
|
|
12848
|
+
for (const project of projects) {
|
|
12849
|
+
const current = existingProjects.find((candidate) => candidate.id === project.id);
|
|
12850
|
+
if (current?.task_list_id === project.task_list_id)
|
|
12851
|
+
continue;
|
|
12852
|
+
const conflict = existingProjects.find((candidate) => candidate.id !== project.id && candidate.task_list_id === project.task_list_id);
|
|
12853
|
+
if (conflict) {
|
|
12854
|
+
errors.push(`project ${project.id}: task_list_id conflicts with existing project ${conflict.id}: ${String(project.task_list_id)}`);
|
|
12855
|
+
}
|
|
12856
|
+
}
|
|
12857
|
+
for (const taskList of taskLists) {
|
|
12858
|
+
const projectId = taskList.project_id ?? null;
|
|
12859
|
+
const current = existingTaskLists.find((candidate) => candidate.id === taskList.id);
|
|
12860
|
+
if ((current?.project_id ?? null) === projectId && current?.slug === taskList.slug)
|
|
12861
|
+
continue;
|
|
12862
|
+
const conflict = existingTaskLists.find((candidate) => candidate.id !== taskList.id && (candidate.project_id ?? null) === projectId && candidate.slug === taskList.slug);
|
|
12863
|
+
if (conflict) {
|
|
12864
|
+
errors.push(`task list ${taskList.id}: slug conflicts with existing task list ${conflict.id} in the same scope: ${String(taskList.slug)}`);
|
|
12865
|
+
}
|
|
12866
|
+
}
|
|
12867
|
+
return errors;
|
|
12868
|
+
}
|
|
12869
|
+
|
|
12870
|
+
// src/db/slug-claims.ts
|
|
12871
|
+
function taskListSlugScopeKey(projectId) {
|
|
12872
|
+
return projectId ? `project:${projectId}` : "standalone:";
|
|
12873
|
+
}
|
|
12874
|
+
function claimCanonicalSlug(kind, scopeKey, slug, objectId, db) {
|
|
12875
|
+
db.run(`INSERT OR IGNORE INTO canonical_slug_claims (kind, scope_key, slug, object_id)
|
|
12876
|
+
VALUES (?, ?, ?, ?)`, [kind, scopeKey, slug, objectId]);
|
|
12877
|
+
const claim = db.query("SELECT object_id FROM canonical_slug_claims WHERE kind = ? AND scope_key = ? AND slug = ?").get(kind, scopeKey, slug);
|
|
12878
|
+
return claim?.object_id === objectId;
|
|
12879
|
+
}
|
|
12880
|
+
function releaseCanonicalSlugClaims(kind, objectId, db) {
|
|
12881
|
+
db.run("DELETE FROM canonical_slug_claims WHERE kind = ? AND object_id = ?", [kind, objectId]);
|
|
12882
|
+
}
|
|
12883
|
+
|
|
12642
12884
|
// src/db/projects.ts
|
|
12643
12885
|
var exports_projects = {};
|
|
12644
12886
|
__export(exports_projects, {
|
|
@@ -12662,7 +12904,7 @@ __export(exports_projects, {
|
|
|
12662
12904
|
addProjectSource: () => addProjectSource
|
|
12663
12905
|
});
|
|
12664
12906
|
function slugify(name) {
|
|
12665
|
-
return name
|
|
12907
|
+
return normalizeSlug(name);
|
|
12666
12908
|
}
|
|
12667
12909
|
function generatePrefix(name, db) {
|
|
12668
12910
|
const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
|
|
@@ -12686,14 +12928,23 @@ function generatePrefix(name, db) {
|
|
|
12686
12928
|
}
|
|
12687
12929
|
function createProject(input, db) {
|
|
12688
12930
|
const d = db || getDatabase();
|
|
12689
|
-
|
|
12690
|
-
|
|
12691
|
-
|
|
12692
|
-
|
|
12693
|
-
|
|
12694
|
-
|
|
12695
|
-
|
|
12696
|
-
|
|
12931
|
+
return d.transaction(() => {
|
|
12932
|
+
const id = uuid();
|
|
12933
|
+
const timestamp = now();
|
|
12934
|
+
const derivedSlug = slugify(input.name);
|
|
12935
|
+
const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
|
|
12936
|
+
if (!derivedSlug || !taskListId)
|
|
12937
|
+
throw new Error("Project name and task-list slug must be non-empty");
|
|
12938
|
+
const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
|
|
12939
|
+
if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
|
|
12940
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
|
|
12941
|
+
}
|
|
12942
|
+
const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
|
|
12943
|
+
const machineId = currentStorageMachineId(d);
|
|
12944
|
+
d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
|
|
12945
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
|
|
12946
|
+
return getProject(id, d);
|
|
12947
|
+
})();
|
|
12697
12948
|
}
|
|
12698
12949
|
function getProject(id, db) {
|
|
12699
12950
|
const d = db || getDatabase();
|
|
@@ -12721,6 +12972,9 @@ function updateProject(id, input, db) {
|
|
|
12721
12972
|
const project = getProject(id, d);
|
|
12722
12973
|
if (!project)
|
|
12723
12974
|
throw new ProjectNotFoundError(id);
|
|
12975
|
+
if ("task_list_id" in input) {
|
|
12976
|
+
throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
|
|
12977
|
+
}
|
|
12724
12978
|
const sets = ["updated_at = ?"];
|
|
12725
12979
|
const params = [now()];
|
|
12726
12980
|
if (input.name !== undefined) {
|
|
@@ -12731,10 +12985,6 @@ function updateProject(id, input, db) {
|
|
|
12731
12985
|
sets.push("description = ?");
|
|
12732
12986
|
params.push(input.description);
|
|
12733
12987
|
}
|
|
12734
|
-
if (input.task_list_id !== undefined) {
|
|
12735
|
-
sets.push("task_list_id = ?");
|
|
12736
|
-
params.push(input.task_list_id);
|
|
12737
|
-
}
|
|
12738
12988
|
if (input.path !== undefined) {
|
|
12739
12989
|
sets.push("path = ?");
|
|
12740
12990
|
params.push(input.path);
|
|
@@ -12745,29 +12995,41 @@ function updateProject(id, input, db) {
|
|
|
12745
12995
|
}
|
|
12746
12996
|
function renameProject(id, input, db) {
|
|
12747
12997
|
const d = db || getDatabase();
|
|
12748
|
-
|
|
12749
|
-
|
|
12750
|
-
|
|
12751
|
-
|
|
12752
|
-
|
|
12753
|
-
|
|
12754
|
-
|
|
12755
|
-
|
|
12756
|
-
|
|
12757
|
-
|
|
12758
|
-
|
|
12759
|
-
|
|
12760
|
-
|
|
12761
|
-
|
|
12762
|
-
|
|
12763
|
-
|
|
12764
|
-
|
|
12998
|
+
return d.transaction(() => {
|
|
12999
|
+
const project = getProject(id, d);
|
|
13000
|
+
if (!project)
|
|
13001
|
+
throw new ProjectNotFoundError(id);
|
|
13002
|
+
let taskListsUpdated = 0;
|
|
13003
|
+
const ts = now();
|
|
13004
|
+
if (input.new_slug !== undefined) {
|
|
13005
|
+
const normalised = normalizeSlug(input.new_slug);
|
|
13006
|
+
if (!normalised)
|
|
13007
|
+
throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
|
|
13008
|
+
const oldSlug = project.task_list_id;
|
|
13009
|
+
if (normalised !== oldSlug) {
|
|
13010
|
+
const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
|
|
13011
|
+
if (conflict) {
|
|
13012
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
|
|
13013
|
+
}
|
|
13014
|
+
const taskListConflict = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND slug != COALESCE(?, '') LIMIT 1").get(id, normalised, oldSlug);
|
|
13015
|
+
if (taskListConflict) {
|
|
13016
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalised}" is already used in project "${project.name}"`);
|
|
13017
|
+
}
|
|
13018
|
+
releaseCanonicalSlugClaims("project", id, d);
|
|
13019
|
+
if (!claimCanonicalSlug("project", "global", normalised, id, d)) {
|
|
13020
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
|
|
13021
|
+
}
|
|
13022
|
+
d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
|
|
13023
|
+
}
|
|
13024
|
+
if (oldSlug && (normalised !== oldSlug || input.name !== undefined && input.name !== project.name)) {
|
|
13025
|
+
taskListsUpdated = d.query("UPDATE task_lists SET slug = ?, name = COALESCE(?, name), updated_at = ? WHERE project_id = ? AND slug = ? RETURNING id").all(normalised, input.name ?? null, ts, id, oldSlug).length;
|
|
13026
|
+
}
|
|
12765
13027
|
}
|
|
12766
|
-
|
|
12767
|
-
|
|
12768
|
-
|
|
12769
|
-
|
|
12770
|
-
|
|
13028
|
+
if (input.name !== undefined && input.name !== project.name) {
|
|
13029
|
+
d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
|
|
13030
|
+
}
|
|
13031
|
+
return { project: getProject(id, d), task_lists_updated: taskListsUpdated };
|
|
13032
|
+
})();
|
|
12771
13033
|
}
|
|
12772
13034
|
function deleteProject(id, db) {
|
|
12773
13035
|
const d = db || getDatabase();
|
|
@@ -12779,8 +13041,10 @@ function deleteProject(id, db) {
|
|
|
12779
13041
|
object_id: id,
|
|
12780
13042
|
payload: project
|
|
12781
13043
|
}, d);
|
|
12782
|
-
|
|
12783
|
-
|
|
13044
|
+
return d.transaction(() => {
|
|
13045
|
+
releaseCanonicalSlugClaims("project", id, d);
|
|
13046
|
+
return d.run("DELETE FROM projects WHERE id = ?", [id]).changes > 0;
|
|
13047
|
+
})();
|
|
12784
13048
|
}
|
|
12785
13049
|
function rowToSource(row) {
|
|
12786
13050
|
return {
|
|
@@ -14468,19 +14732,22 @@ function rowToTaskList(row) {
|
|
|
14468
14732
|
}
|
|
14469
14733
|
function createTaskList(input, db) {
|
|
14470
14734
|
const d = db || getDatabase();
|
|
14471
|
-
|
|
14472
|
-
|
|
14473
|
-
|
|
14474
|
-
|
|
14475
|
-
|
|
14476
|
-
|
|
14477
|
-
|
|
14478
|
-
|
|
14479
|
-
|
|
14480
|
-
|
|
14481
|
-
|
|
14482
|
-
|
|
14483
|
-
|
|
14735
|
+
return d.transaction(() => {
|
|
14736
|
+
const id = uuid();
|
|
14737
|
+
const timestamp = now();
|
|
14738
|
+
const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
|
|
14739
|
+
if (!slug)
|
|
14740
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
14741
|
+
const machineId = currentStorageMachineId(d);
|
|
14742
|
+
const scopeKey = taskListSlugScopeKey(input.project_id);
|
|
14743
|
+
const existing = input.project_id ? d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ?").get(input.project_id, slug) : d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
|
|
14744
|
+
if (existing || !claimCanonicalSlug("task_list", scopeKey, slug, id, d)) {
|
|
14745
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
14746
|
+
}
|
|
14747
|
+
d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
|
|
14748
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
|
|
14749
|
+
return getTaskList(id, d);
|
|
14750
|
+
})();
|
|
14484
14751
|
}
|
|
14485
14752
|
function getTaskList(id, db) {
|
|
14486
14753
|
const d = db || getDatabase();
|
|
@@ -14506,26 +14773,45 @@ function listTaskLists(projectId, db) {
|
|
|
14506
14773
|
}
|
|
14507
14774
|
function updateTaskList(id, input, db) {
|
|
14508
14775
|
const d = db || getDatabase();
|
|
14509
|
-
|
|
14510
|
-
|
|
14511
|
-
|
|
14512
|
-
|
|
14513
|
-
|
|
14514
|
-
|
|
14515
|
-
|
|
14516
|
-
|
|
14517
|
-
|
|
14518
|
-
|
|
14519
|
-
|
|
14520
|
-
|
|
14521
|
-
|
|
14522
|
-
|
|
14523
|
-
|
|
14524
|
-
|
|
14525
|
-
|
|
14526
|
-
|
|
14527
|
-
|
|
14528
|
-
|
|
14776
|
+
return d.transaction(() => {
|
|
14777
|
+
const existing = getTaskList(id, d);
|
|
14778
|
+
if (!existing)
|
|
14779
|
+
throw new TaskListNotFoundError(id);
|
|
14780
|
+
const sets = ["updated_at = ?"];
|
|
14781
|
+
const params = [now()];
|
|
14782
|
+
if (input.slug !== undefined) {
|
|
14783
|
+
const slug = slugify(input.slug);
|
|
14784
|
+
if (!slug)
|
|
14785
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
14786
|
+
const duplicate = existing.project_id ? d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND id != ?").get(existing.project_id, slug, id) : d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ? AND id != ?").get(slug, id);
|
|
14787
|
+
if (duplicate) {
|
|
14788
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
14789
|
+
}
|
|
14790
|
+
if (slug !== existing.slug) {
|
|
14791
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
14792
|
+
if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(existing.project_id), slug, id, d)) {
|
|
14793
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
14794
|
+
}
|
|
14795
|
+
}
|
|
14796
|
+
sets.push("slug = ?");
|
|
14797
|
+
params.push(slug);
|
|
14798
|
+
}
|
|
14799
|
+
if (input.name !== undefined) {
|
|
14800
|
+
sets.push("name = ?");
|
|
14801
|
+
params.push(input.name);
|
|
14802
|
+
}
|
|
14803
|
+
if (input.description !== undefined) {
|
|
14804
|
+
sets.push("description = ?");
|
|
14805
|
+
params.push(input.description);
|
|
14806
|
+
}
|
|
14807
|
+
if (input.metadata !== undefined) {
|
|
14808
|
+
sets.push("metadata = ?");
|
|
14809
|
+
params.push(JSON.stringify(input.metadata));
|
|
14810
|
+
}
|
|
14811
|
+
params.push(id);
|
|
14812
|
+
d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
|
|
14813
|
+
return getTaskList(id, d);
|
|
14814
|
+
})();
|
|
14529
14815
|
}
|
|
14530
14816
|
function deleteTaskList(id, db) {
|
|
14531
14817
|
const d = db || getDatabase();
|
|
@@ -14537,7 +14823,10 @@ function deleteTaskList(id, db) {
|
|
|
14537
14823
|
object_id: id,
|
|
14538
14824
|
payload: list
|
|
14539
14825
|
}, d);
|
|
14540
|
-
return d.
|
|
14826
|
+
return d.transaction(() => {
|
|
14827
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
14828
|
+
return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
14829
|
+
})();
|
|
14541
14830
|
}
|
|
14542
14831
|
function ensureTaskList(name, slug, projectId, db) {
|
|
14543
14832
|
const d = db || getDatabase();
|
|
@@ -20529,7 +20818,8 @@ function registerTaskCommands(program2) {
|
|
|
20529
20818
|
if (cloud) {
|
|
20530
20819
|
let task3;
|
|
20531
20820
|
try {
|
|
20532
|
-
const
|
|
20821
|
+
const cloudProjectRef = opts.project || globalOpts.project;
|
|
20822
|
+
const cloudProjectId = cloudProjectRef ? await cloudResolveProjectRef(cloud, cloudProjectRef) : undefined;
|
|
20533
20823
|
const cloudTaskListId = opts.list ? await cloudResolveTaskListRef(cloud, opts.list, cloudProjectId) : undefined;
|
|
20534
20824
|
if (opts.list && !cloudTaskListId) {
|
|
20535
20825
|
throw new Error(`Could not resolve task list ID or slug: ${opts.list}`);
|
|
@@ -20743,12 +21033,12 @@ function registerTaskCommands(program2) {
|
|
|
20743
21033
|
console.log(` ${chalk2.dim("Manifest:")} ${state.pointers.latest_manifest_path}`);
|
|
20744
21034
|
}
|
|
20745
21035
|
});
|
|
20746
|
-
program2.command("list").description("List tasks").option("-s, --status <status>", "Filter by status").option("-p, --priority <priority>", "Filter by priority").option("--assigned <agent>", "Filter by assigned agent").option("--tags <tags>", "Filter by tags (comma-separated)").option("--tag <tags>", "Filter by tags (alias for --tags)").option("-a, --all", "Show all tasks (including completed/cancelled)").option("--list <
|
|
21036
|
+
program2.command("list").description("List tasks").option("-s, --status <status>", "Filter by status").option("-p, --priority <priority>", "Filter by priority").option("--assigned <agent>", "Filter by assigned agent").option("--tags <tags>", "Filter by tags (comma-separated)").option("--tag <tags>", "Filter by tags (alias for --tags)").option("-a, --all", "Show all tasks (including completed/cancelled)").option("--list <ref>", "Filter by task list UUID, unique UUID prefix, or project-scoped slug").option("--task-list <ref>", "Filter by task list UUID, unique UUID prefix, or project-scoped slug (alias for --list)").option("--project-name <name>", "Filter by project name").option("--agent-name <name>", "Filter by agent name/assigned").option("--sort <field>", "Sort by: updated, created, priority, status").option("--format <fmt>", "Output format: table (default), compact, csv, json").option("--due-today", "Only tasks due today or earlier").option("--overdue", "Only overdue tasks (past due_at)").option("--recurring", "Only recurring tasks").option("--limit <n>", "Max tasks to return").action(async (opts) => {
|
|
20747
21037
|
const globalOpts = program2.opts();
|
|
20748
21038
|
opts.tags = opts.tags || opts.tag;
|
|
20749
21039
|
opts.list = opts.list || opts.taskList;
|
|
20750
21040
|
const cloud = getTodosCloudClient();
|
|
20751
|
-
const projectId = cloud ? undefined : autoProject(globalOpts);
|
|
21041
|
+
const projectId = cloud && globalOpts.project ? await cloudResolveProjectRef(cloud, globalOpts.project) : cloud ? undefined : autoProject(globalOpts);
|
|
20752
21042
|
const hasAssignedFilter = Boolean(opts.assigned || opts.agentName);
|
|
20753
21043
|
const hasExplicitProjectFilter = Boolean(globalOpts.project || opts.projectName);
|
|
20754
21044
|
const allowedSortFields = new Set(["updated", "created", "priority", "status"]);
|
|
@@ -20766,7 +21056,7 @@ function registerTaskCommands(program2) {
|
|
|
20766
21056
|
filter["project_id"] = projectId;
|
|
20767
21057
|
}
|
|
20768
21058
|
if (opts.list && cloud) {
|
|
20769
|
-
filter["task_list_id"] = opts.list;
|
|
21059
|
+
filter["task_list_id"] = await cloudResolveTaskListRef(cloud, opts.list, projectId);
|
|
20770
21060
|
} else if (opts.list) {
|
|
20771
21061
|
const db = getDatabase();
|
|
20772
21062
|
const listId = resolvePartialId(db, "task_lists", opts.list);
|
|
@@ -24001,10 +24291,10 @@ function bootstrapProject(options = {}, db) {
|
|
|
24001
24291
|
let project = ensureProject(options.name || discovery.projectName, discovery.projectPath, d);
|
|
24002
24292
|
const createdProject = !beforeProject;
|
|
24003
24293
|
if (project.task_list_id !== taskListSlug || options.name && project.name !== options.name) {
|
|
24004
|
-
project =
|
|
24294
|
+
project = renameProject(project.id, {
|
|
24005
24295
|
name: options.name ?? project.name,
|
|
24006
|
-
|
|
24007
|
-
}, d);
|
|
24296
|
+
new_slug: taskListSlug
|
|
24297
|
+
}, d).project;
|
|
24008
24298
|
}
|
|
24009
24299
|
setMachineLocalPath(project.id, discovery.projectPath, d);
|
|
24010
24300
|
const beforeTaskList = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ?").get(project.id, taskListSlug);
|
|
@@ -32346,7 +32636,7 @@ function registerProjectCommands(program2) {
|
|
|
32346
32636
|
if (existing) {
|
|
32347
32637
|
project = existing;
|
|
32348
32638
|
if (opts.taskListId) {
|
|
32349
|
-
project =
|
|
32639
|
+
project = renameProject(existing.id, { new_slug: opts.taskListId }).project;
|
|
32350
32640
|
}
|
|
32351
32641
|
} else {
|
|
32352
32642
|
project = createProject({ name, path: projectPath, task_list_id: opts.taskListId });
|
|
@@ -32411,18 +32701,23 @@ function registerProjectCommands(program2) {
|
|
|
32411
32701
|
const globalOpts = program2.opts();
|
|
32412
32702
|
const useJson = opts.json || globalOpts.json;
|
|
32413
32703
|
try {
|
|
32414
|
-
const
|
|
32415
|
-
|
|
32416
|
-
|
|
32417
|
-
|
|
32418
|
-
|
|
32419
|
-
|
|
32420
|
-
|
|
32421
|
-
|
|
32422
|
-
|
|
32423
|
-
|
|
32704
|
+
const cloud = getTodosCloudClient();
|
|
32705
|
+
let result;
|
|
32706
|
+
if (cloud) {
|
|
32707
|
+
result = await cloudRenameProject(cloud, idOrSlug, newSlug, opts.name);
|
|
32708
|
+
} else {
|
|
32709
|
+
const db = getDatabase();
|
|
32710
|
+
let resolvedId = resolvePartialId(db, "projects", idOrSlug);
|
|
32711
|
+
if (!resolvedId) {
|
|
32712
|
+
const bySlug = db.query("SELECT id FROM projects WHERE task_list_id = ?").get(idOrSlug);
|
|
32713
|
+
resolvedId = bySlug?.id ?? null;
|
|
32714
|
+
}
|
|
32715
|
+
if (!resolvedId) {
|
|
32716
|
+
console.error(chalk4.red(`Project not found: ${idOrSlug}`));
|
|
32717
|
+
process.exit(1);
|
|
32718
|
+
}
|
|
32719
|
+
result = renameProject(resolvedId, { name: opts.name, new_slug: newSlug });
|
|
32424
32720
|
}
|
|
32425
|
-
const result = renameProject2(resolvedId, { name: opts.name, new_slug: newSlug });
|
|
32426
32721
|
if (useJson) {
|
|
32427
32722
|
output({ project: result.project, task_lists_updated: result.task_lists_updated }, true);
|
|
32428
32723
|
} else {
|
|
@@ -33777,7 +34072,7 @@ ${isOnline ? chalk5.green("\u25CF") : chalk5.dim("\u25CB")} ${chalk5.bold(agent.
|
|
|
33777
34072
|
try {
|
|
33778
34073
|
const globalOpts = program2.opts();
|
|
33779
34074
|
const cloud = getTodosCloudClient();
|
|
33780
|
-
const projectId = cloud ? globalOpts.project : autoProject(globalOpts);
|
|
34075
|
+
const projectId = cloud ? globalOpts.project ? await cloudResolveProjectRef(cloud, globalOpts.project) : undefined : autoProject(globalOpts);
|
|
33781
34076
|
if (opts.add) {
|
|
33782
34077
|
const input = { name: opts.add, slug: opts.slug, description: opts.description, project_id: projectId };
|
|
33783
34078
|
const list = cloud ? await cloudCreateTaskList(cloud, input) : createTaskList(input);
|
|
@@ -37711,6 +38006,14 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
37711
38006
|
skipped: 0,
|
|
37712
38007
|
errors: []
|
|
37713
38008
|
};
|
|
38009
|
+
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
38010
|
+
if (result.errors.length === 0) {
|
|
38011
|
+
const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
|
|
38012
|
+
const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
|
|
38013
|
+
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
38014
|
+
}
|
|
38015
|
+
if (result.errors.length > 0)
|
|
38016
|
+
return result;
|
|
37714
38017
|
const applyRows = (objectType3, table, columns, rows, updateClockColumn, afterUpsert) => {
|
|
37715
38018
|
for (const row of rows) {
|
|
37716
38019
|
try {
|
|
@@ -38083,6 +38386,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
38083
38386
|
getByPath: (path) => getProjectByPath(path, database()),
|
|
38084
38387
|
list: () => listProjects(database()),
|
|
38085
38388
|
update: (id, input) => updateProject(id, input, database()),
|
|
38389
|
+
rename: (id, input) => renameProject(id, input, database()),
|
|
38086
38390
|
delete: (id) => deleteProject(id, database())
|
|
38087
38391
|
},
|
|
38088
38392
|
plans: {
|
|
@@ -38190,6 +38494,91 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
38190
38494
|
)`
|
|
38191
38495
|
];
|
|
38192
38496
|
}
|
|
38497
|
+
function postgresTodosScopedSlugPreflightSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
38498
|
+
assertSafeIdentifier(tableName);
|
|
38499
|
+
return `/* todos:scoped-slug-duplicate-audit */ WITH candidates AS (
|
|
38500
|
+
SELECT service, object_type, COALESCE(payload->>'project_id', '') AS scope,
|
|
38501
|
+
jsonb_typeof(payload->'project_id') AS scope_type,
|
|
38502
|
+
payload->>'slug' AS slug, jsonb_typeof(payload->'slug') AS slug_type, object_id
|
|
38503
|
+
FROM ${tableName}
|
|
38504
|
+
WHERE object_type = 'task_lists' AND deleted_at IS NULL
|
|
38505
|
+
UNION ALL
|
|
38506
|
+
SELECT service, object_type, '' AS scope, NULL::text AS scope_type, payload->>'task_list_id' AS slug,
|
|
38507
|
+
jsonb_typeof(payload->'task_list_id') AS slug_type, object_id
|
|
38508
|
+
FROM ${tableName}
|
|
38509
|
+
WHERE object_type = 'projects' AND deleted_at IS NULL
|
|
38510
|
+
), annotated AS (
|
|
38511
|
+
SELECT *, trim(both '-' from regexp_replace(lower(COALESCE(slug, '')), '[^a-z0-9]+', '-', 'g')) AS normalized_slug
|
|
38512
|
+
FROM candidates
|
|
38513
|
+
), invalid AS (
|
|
38514
|
+
SELECT service, object_type, scope, COALESCE(slug, '<null>') AS slug,
|
|
38515
|
+
ARRAY[object_id] AS object_ids, 1::integer AS duplicate_count, 'invalid'::text AS issue
|
|
38516
|
+
FROM annotated
|
|
38517
|
+
WHERE slug_type IS DISTINCT FROM 'string'
|
|
38518
|
+
OR slug IS NULL OR slug = '' OR normalized_slug = '' OR slug IS DISTINCT FROM normalized_slug
|
|
38519
|
+
OR (object_type = 'task_lists' AND (
|
|
38520
|
+
(scope_type IS NOT NULL AND scope_type NOT IN ('string', 'null'))
|
|
38521
|
+
OR (scope_type = 'string' AND btrim(scope) = '')
|
|
38522
|
+
))
|
|
38523
|
+
), duplicates AS (
|
|
38524
|
+
SELECT service, object_type, scope, slug,
|
|
38525
|
+
array_agg(object_id ORDER BY object_id) AS object_ids,
|
|
38526
|
+
count(*)::integer AS duplicate_count, 'duplicate'::text AS issue
|
|
38527
|
+
FROM annotated
|
|
38528
|
+
WHERE slug = normalized_slug AND slug <> ''
|
|
38529
|
+
GROUP BY service, object_type, scope, slug
|
|
38530
|
+
HAVING count(*) > 1
|
|
38531
|
+
) SELECT * FROM invalid
|
|
38532
|
+
UNION ALL SELECT * FROM duplicates
|
|
38533
|
+
ORDER BY service, object_type, scope, slug`;
|
|
38534
|
+
}
|
|
38535
|
+
function postgresTodosScopedSlugUniqueIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
38536
|
+
assertSafeIdentifier(tableName);
|
|
38537
|
+
return [
|
|
38538
|
+
`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_task_list_scope_slug_uidx
|
|
38539
|
+
ON ${tableName} (service, COALESCE(payload->>'project_id', ''), (payload->>'slug'))
|
|
38540
|
+
WHERE object_type = 'task_lists' AND deleted_at IS NULL AND COALESCE(payload->>'slug', '') <> ''`,
|
|
38541
|
+
`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_project_task_list_slug_uidx
|
|
38542
|
+
ON ${tableName} (service, (payload->>'task_list_id'))
|
|
38543
|
+
WHERE object_type = 'projects' AND deleted_at IS NULL AND COALESCE(payload->>'task_list_id', '') <> ''`
|
|
38544
|
+
];
|
|
38545
|
+
}
|
|
38546
|
+
function postgresTodosScopedSlugIndexStatusSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
38547
|
+
assertSafeIdentifier(tableName);
|
|
38548
|
+
return `/* todos:scoped-slug-index-status */ SELECT index_class.relname AS index_name,
|
|
38549
|
+
index_meta.indisvalid AS is_valid, index_meta.indisready AS is_ready
|
|
38550
|
+
FROM pg_index index_meta
|
|
38551
|
+
JOIN pg_class index_class ON index_class.oid = index_meta.indexrelid
|
|
38552
|
+
WHERE index_meta.indrelid = to_regclass('${tableName}')
|
|
38553
|
+
AND index_class.relname IN (
|
|
38554
|
+
'${tableName}_task_list_scope_slug_uidx',
|
|
38555
|
+
'${tableName}_project_task_list_slug_uidx'
|
|
38556
|
+
)`;
|
|
38557
|
+
}
|
|
38558
|
+
async function ensurePostgresScopedSlugUniqueIndexes(client, tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
38559
|
+
const audit = await client.query(postgresTodosScopedSlugPreflightSql(tableName));
|
|
38560
|
+
if (audit.rows.length > 0)
|
|
38561
|
+
throw new PostgresScopedSlugMigrationConflictError(audit.rows);
|
|
38562
|
+
for (const sql of postgresTodosScopedSlugUniqueIndexSql(tableName)) {
|
|
38563
|
+
try {
|
|
38564
|
+
await client.query(sql);
|
|
38565
|
+
} catch (error) {
|
|
38566
|
+
const indexName = sql.match(/INDEX CONCURRENTLY IF NOT EXISTS ([a-zA-Z0-9_]+)/)?.[1] ?? "unknown_index";
|
|
38567
|
+
throw new PostgresScopedSlugIndexBuildError(indexName, error);
|
|
38568
|
+
}
|
|
38569
|
+
}
|
|
38570
|
+
const expected = new Set([
|
|
38571
|
+
`${tableName}_task_list_scope_slug_uidx`,
|
|
38572
|
+
`${tableName}_project_task_list_slug_uidx`
|
|
38573
|
+
]);
|
|
38574
|
+
const status = await client.query(postgresTodosScopedSlugIndexStatusSql(tableName));
|
|
38575
|
+
for (const indexName of expected) {
|
|
38576
|
+
const row = status.rows.find((candidate) => candidate.index_name === indexName);
|
|
38577
|
+
if (!row?.is_valid || !row.is_ready) {
|
|
38578
|
+
throw new PostgresScopedSlugIndexBuildError(indexName, new Error("index is missing, invalid, or not ready"));
|
|
38579
|
+
}
|
|
38580
|
+
}
|
|
38581
|
+
}
|
|
38193
38582
|
function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
38194
38583
|
assertSafeIdentifier(tableName);
|
|
38195
38584
|
return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
|
|
@@ -38218,9 +38607,31 @@ class PostgresTodosSyncStore {
|
|
|
38218
38607
|
}
|
|
38219
38608
|
}
|
|
38220
38609
|
async pushSnapshot(snapshot, context = {}) {
|
|
38610
|
+
const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
|
|
38611
|
+
if (routingErrors.length > 0) {
|
|
38612
|
+
throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
|
|
38613
|
+
}
|
|
38614
|
+
const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
|
|
38615
|
+
FROM ${this.tableName}
|
|
38616
|
+
WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
|
|
38617
|
+
const existingProjects = [];
|
|
38618
|
+
const existingTaskLists = [];
|
|
38619
|
+
for (const row of existing.rows) {
|
|
38620
|
+
const payload = payloadRecord(row.payload);
|
|
38621
|
+
if (row.object_type === "projects")
|
|
38622
|
+
existingProjects.push(payload);
|
|
38623
|
+
if (row.object_type === "task_lists")
|
|
38624
|
+
existingTaskLists.push(payload);
|
|
38625
|
+
}
|
|
38626
|
+
const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
|
|
38627
|
+
if (destinationErrors.length > 0) {
|
|
38628
|
+
throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
|
|
38629
|
+
}
|
|
38221
38630
|
const result = { records: 0, objectTypes: {} };
|
|
38222
38631
|
const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
|
|
38223
38632
|
for (const entry of snapshotEntries(snapshot)) {
|
|
38633
|
+
if (entry.deletedAt === null)
|
|
38634
|
+
assertCanonicalScopedSlugEntry(entry);
|
|
38224
38635
|
await this.client.query(`INSERT INTO ${this.tableName} (
|
|
38225
38636
|
service, object_type, object_id, payload, updated_at,
|
|
38226
38637
|
deleted_at, source_machine_id, version
|
|
@@ -38299,6 +38710,22 @@ function snapshotEntries(snapshot) {
|
|
|
38299
38710
|
}))
|
|
38300
38711
|
];
|
|
38301
38712
|
}
|
|
38713
|
+
function assertCanonicalScopedSlugEntry(entry) {
|
|
38714
|
+
if (!entry.payload || typeof entry.payload !== "object" || Array.isArray(entry.payload))
|
|
38715
|
+
return;
|
|
38716
|
+
const payload = entry.payload;
|
|
38717
|
+
if (entry.type === "projects" && !isCanonicalSlug(payload["task_list_id"])) {
|
|
38718
|
+
throw new Error("Invalid project task-list slug \u2014 sync requires non-empty canonical kebab-case");
|
|
38719
|
+
}
|
|
38720
|
+
if (entry.type === "task_lists") {
|
|
38721
|
+
if (!isCanonicalSlug(payload["slug"])) {
|
|
38722
|
+
throw new Error("Invalid task-list slug \u2014 sync requires non-empty canonical kebab-case");
|
|
38723
|
+
}
|
|
38724
|
+
if (!isValidTaskListProjectScope(payload["project_id"])) {
|
|
38725
|
+
throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
|
|
38726
|
+
}
|
|
38727
|
+
}
|
|
38728
|
+
}
|
|
38302
38729
|
function entry(type, payload, fallbackUpdatedAt) {
|
|
38303
38730
|
const id = payload["id"];
|
|
38304
38731
|
if (typeof id !== "string" || !id)
|
|
@@ -38381,7 +38808,26 @@ function assertSafeIdentifier(value) {
|
|
|
38381
38808
|
if (!/^[a-z_][a-z0-9_]*$/i.test(value))
|
|
38382
38809
|
throw new Error(`Unsafe Postgres identifier: ${value}`);
|
|
38383
38810
|
}
|
|
38384
|
-
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
|
|
38811
|
+
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors", PostgresScopedSlugMigrationConflictError, PostgresScopedSlugIndexBuildError;
|
|
38812
|
+
var init_postgres_sync = __esm(() => {
|
|
38813
|
+
PostgresScopedSlugMigrationConflictError = class PostgresScopedSlugMigrationConflictError extends Error {
|
|
38814
|
+
conflicts;
|
|
38815
|
+
constructor(conflicts) {
|
|
38816
|
+
const preview = conflicts.slice(0, 5).map((conflict) => `${conflict.object_type}:${conflict.scope || "global"}:${conflict.slug} [${conflict.object_ids.join(", ")}]`).join("; ");
|
|
38817
|
+
super(`Scoped slug unique-index preflight found ${conflicts.length} invalid or duplicate slug conflict(s): ${preview}. ` + "Resolve these records explicitly without deleting history, then rerun todos-serve migrate.");
|
|
38818
|
+
this.conflicts = conflicts;
|
|
38819
|
+
this.name = "PostgresScopedSlugMigrationConflictError";
|
|
38820
|
+
}
|
|
38821
|
+
};
|
|
38822
|
+
PostgresScopedSlugIndexBuildError = class PostgresScopedSlugIndexBuildError extends Error {
|
|
38823
|
+
index_name;
|
|
38824
|
+
constructor(index_name, cause) {
|
|
38825
|
+
super(`Concurrent scoped-slug index build failed for ${index_name} after a clean duplicate audit. ` + "No records were rewritten; inspect pg_index for an invalid index, resolve any concurrent duplicate, and rerun todos-serve migrate.", { cause });
|
|
38826
|
+
this.index_name = index_name;
|
|
38827
|
+
this.name = "PostgresScopedSlugIndexBuildError";
|
|
38828
|
+
}
|
|
38829
|
+
};
|
|
38830
|
+
});
|
|
38385
38831
|
|
|
38386
38832
|
// src/storage/shadow-outbox.ts
|
|
38387
38833
|
class TodosShadowOutbox {
|
|
@@ -38633,6 +39079,7 @@ function createTodosShadowOutbox(options) {
|
|
|
38633
39079
|
var MAX_BACKOFF_MS;
|
|
38634
39080
|
var init_shadow_outbox = __esm(() => {
|
|
38635
39081
|
init_local_sqlite();
|
|
39082
|
+
init_postgres_sync();
|
|
38636
39083
|
init_shadow_outbox_schema();
|
|
38637
39084
|
init_shadow_outbox_schema();
|
|
38638
39085
|
MAX_BACKOFF_MS = 5 * 60000;
|
|
@@ -39137,6 +39584,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
39137
39584
|
getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
|
|
39138
39585
|
list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
|
|
39139
39586
|
update: (id, input) => updateProject2(id, input, store),
|
|
39587
|
+
rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
|
|
39140
39588
|
delete: (id, context) => store.delete("projects", id, context)
|
|
39141
39589
|
},
|
|
39142
39590
|
plans: {
|
|
@@ -39374,9 +39822,22 @@ class PostgresJsonRecordStore {
|
|
|
39374
39822
|
});
|
|
39375
39823
|
}
|
|
39376
39824
|
async upsert(type, value, context = {}) {
|
|
39825
|
+
if (type === "projects" && !isCanonicalSlug(value.task_list_id)) {
|
|
39826
|
+
throw new Error("Invalid project task-list slug \u2014 imports require non-empty canonical kebab-case");
|
|
39827
|
+
}
|
|
39828
|
+
if (type === "task_lists") {
|
|
39829
|
+
if (!isCanonicalSlug(value.slug)) {
|
|
39830
|
+
throw new Error("Invalid task-list slug \u2014 imports require non-empty canonical kebab-case");
|
|
39831
|
+
}
|
|
39832
|
+
if (!isValidTaskListProjectScope(value.project_id)) {
|
|
39833
|
+
throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
|
|
39834
|
+
}
|
|
39835
|
+
}
|
|
39377
39836
|
await this.ensureSchema();
|
|
39378
39837
|
const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
|
|
39379
|
-
|
|
39838
|
+
let result;
|
|
39839
|
+
try {
|
|
39840
|
+
result = await this.options.client.query(`INSERT INTO ${this.tableName} (
|
|
39380
39841
|
service, object_type, object_id, payload, updated_at,
|
|
39381
39842
|
deleted_at, source_machine_id, version
|
|
39382
39843
|
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, $7)
|
|
@@ -39391,14 +39852,23 @@ class PostgresJsonRecordStore {
|
|
|
39391
39852
|
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
39392
39853
|
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
|
|
39393
39854
|
RETURNING object_id`, [
|
|
39394
|
-
|
|
39395
|
-
|
|
39396
|
-
|
|
39397
|
-
|
|
39398
|
-
|
|
39399
|
-
|
|
39400
|
-
|
|
39401
|
-
|
|
39855
|
+
this.service,
|
|
39856
|
+
type,
|
|
39857
|
+
value.id,
|
|
39858
|
+
jsonbParam(value),
|
|
39859
|
+
updatedAt,
|
|
39860
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
39861
|
+
numberValue2(value.version)
|
|
39862
|
+
]);
|
|
39863
|
+
} catch (error) {
|
|
39864
|
+
if (type === "task_lists" && isPostgresUniqueViolation(error)) {
|
|
39865
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${String(value.slug ?? "")}" already exists in this scope`);
|
|
39866
|
+
}
|
|
39867
|
+
if (type === "projects" && isPostgresUniqueViolation(error)) {
|
|
39868
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${String(value.task_list_id ?? "")}" already exists`);
|
|
39869
|
+
}
|
|
39870
|
+
throw error;
|
|
39871
|
+
}
|
|
39402
39872
|
if (result.rows.length === 0) {
|
|
39403
39873
|
const current = await this.get(type, value.id);
|
|
39404
39874
|
if (current)
|
|
@@ -39406,6 +39876,93 @@ class PostgresJsonRecordStore {
|
|
|
39406
39876
|
}
|
|
39407
39877
|
return value;
|
|
39408
39878
|
}
|
|
39879
|
+
async renameProject(id, newSlug, name, context = {}) {
|
|
39880
|
+
await this.ensureSchema();
|
|
39881
|
+
const normalizedSlug = slugifyRaw(newSlug);
|
|
39882
|
+
if (!normalizedSlug)
|
|
39883
|
+
throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
|
|
39884
|
+
const timestamp = new Date().toISOString();
|
|
39885
|
+
try {
|
|
39886
|
+
const result = await this.options.client.query(`/* todos:rename-project-atomic */ WITH target AS (
|
|
39887
|
+
SELECT payload, payload->>'task_list_id' AS old_slug
|
|
39888
|
+
FROM ${this.tableName}
|
|
39889
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL
|
|
39890
|
+
FOR UPDATE
|
|
39891
|
+
), project_conflict AS (
|
|
39892
|
+
SELECT 1 FROM ${this.tableName}
|
|
39893
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
39894
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3 LIMIT 1
|
|
39895
|
+
), task_list_conflict AS (
|
|
39896
|
+
SELECT 1 FROM ${this.tableName} r, target
|
|
39897
|
+
WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
|
|
39898
|
+
AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = $3
|
|
39899
|
+
AND r.payload->>'slug' IS DISTINCT FROM target.old_slug LIMIT 1
|
|
39900
|
+
), updated_lists AS (
|
|
39901
|
+
UPDATE ${this.tableName} r SET
|
|
39902
|
+
payload = r.payload || jsonb_build_object('slug', $3::text, 'updated_at', $5::text)
|
|
39903
|
+
|| CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
|
|
39904
|
+
updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
|
|
39905
|
+
source_machine_id = COALESCE($6, r.source_machine_id)
|
|
39906
|
+
FROM target
|
|
39907
|
+
WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
|
|
39908
|
+
AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = target.old_slug
|
|
39909
|
+
AND NOT EXISTS (SELECT 1 FROM project_conflict)
|
|
39910
|
+
AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
|
|
39911
|
+
AND (target.old_slug IS DISTINCT FROM $3
|
|
39912
|
+
OR ($4::text IS NOT NULL AND r.payload->>'name' IS DISTINCT FROM $4))
|
|
39913
|
+
RETURNING 1
|
|
39914
|
+
), updated_project AS (
|
|
39915
|
+
UPDATE ${this.tableName} r SET
|
|
39916
|
+
payload = r.payload || jsonb_build_object('task_list_id', $3::text, 'updated_at', $5::text)
|
|
39917
|
+
|| CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
|
|
39918
|
+
updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
|
|
39919
|
+
source_machine_id = COALESCE($6, r.source_machine_id)
|
|
39920
|
+
FROM target
|
|
39921
|
+
WHERE r.service = $1 AND r.object_type = 'projects' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
39922
|
+
AND NOT EXISTS (SELECT 1 FROM project_conflict)
|
|
39923
|
+
AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
|
|
39924
|
+
AND (target.old_slug IS DISTINCT FROM $3
|
|
39925
|
+
OR ($4::text IS NOT NULL AND target.payload->>'name' IS DISTINCT FROM $4))
|
|
39926
|
+
RETURNING r.payload
|
|
39927
|
+
) SELECT
|
|
39928
|
+
EXISTS (SELECT 1 FROM target) AS found,
|
|
39929
|
+
EXISTS (SELECT 1 FROM project_conflict) AS project_conflict,
|
|
39930
|
+
EXISTS (SELECT 1 FROM task_list_conflict) AS task_list_conflict,
|
|
39931
|
+
COALESCE((SELECT payload FROM updated_project), (SELECT payload FROM target)) AS project,
|
|
39932
|
+
(SELECT count(*) FROM updated_lists) AS task_lists_updated`, [this.service, id, normalizedSlug, name ?? null, timestamp, this.machineId(context)]);
|
|
39933
|
+
const row = result.rows[0];
|
|
39934
|
+
if (!row?.found)
|
|
39935
|
+
throw new ProjectNotFoundError(id);
|
|
39936
|
+
if (row.project_conflict) {
|
|
39937
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
|
|
39938
|
+
}
|
|
39939
|
+
if (row.task_list_conflict) {
|
|
39940
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
|
|
39941
|
+
}
|
|
39942
|
+
return {
|
|
39943
|
+
project: payloadRecord2(row.project),
|
|
39944
|
+
task_lists_updated: Number(row.task_lists_updated)
|
|
39945
|
+
};
|
|
39946
|
+
} catch (error) {
|
|
39947
|
+
if (isPostgresUniqueViolation(error)) {
|
|
39948
|
+
const constraintName = postgresConstraintName(error);
|
|
39949
|
+
let projectConflict = constraintName.includes("project_task_list_slug_uidx");
|
|
39950
|
+
if (!constraintName) {
|
|
39951
|
+
const conflict = await this.options.client.query(`/* todos:classify-project-rename-conflict */ SELECT EXISTS (
|
|
39952
|
+
SELECT 1 FROM ${this.tableName}
|
|
39953
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
39954
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3
|
|
39955
|
+
) AS project_conflict`, [this.service, id, normalizedSlug]);
|
|
39956
|
+
projectConflict = Boolean(conflict.rows[0]?.project_conflict);
|
|
39957
|
+
}
|
|
39958
|
+
if (projectConflict) {
|
|
39959
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
|
|
39960
|
+
}
|
|
39961
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
|
|
39962
|
+
}
|
|
39963
|
+
throw error;
|
|
39964
|
+
}
|
|
39965
|
+
}
|
|
39409
39966
|
async incrementProjectTaskCounter(projectId, _context = {}) {
|
|
39410
39967
|
await this.ensureSchema();
|
|
39411
39968
|
const result = await this.options.client.query(`UPDATE ${this.tableName}
|
|
@@ -39825,12 +40382,16 @@ async function getChangedSince(since, filters, store) {
|
|
|
39825
40382
|
}
|
|
39826
40383
|
async function createProject2(input, store, context) {
|
|
39827
40384
|
const timestamp = new Date().toISOString();
|
|
40385
|
+
const derivedSlug = slugifyRaw(input.name);
|
|
40386
|
+
const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
|
|
40387
|
+
if (!derivedSlug || !taskListId)
|
|
40388
|
+
throw new Error("Project name and task-list slug must be non-empty");
|
|
39828
40389
|
const project = {
|
|
39829
40390
|
id: randomUUID3(),
|
|
39830
40391
|
name: input.name,
|
|
39831
40392
|
path: input.path,
|
|
39832
40393
|
description: input.description ?? null,
|
|
39833
|
-
task_list_id:
|
|
40394
|
+
task_list_id: taskListId,
|
|
39834
40395
|
task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
|
|
39835
40396
|
task_counter: 0,
|
|
39836
40397
|
created_at: timestamp,
|
|
@@ -39841,6 +40402,9 @@ async function createProject2(input, store, context) {
|
|
|
39841
40402
|
return store.upsert("projects", project, context);
|
|
39842
40403
|
}
|
|
39843
40404
|
async function updateProject2(id, input, store) {
|
|
40405
|
+
if ("task_list_id" in input) {
|
|
40406
|
+
throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
|
|
40407
|
+
}
|
|
39844
40408
|
const project = await requireRecord("projects", id, store);
|
|
39845
40409
|
const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
|
|
39846
40410
|
return store.upsert("projects", updated);
|
|
@@ -39949,10 +40513,13 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
|
|
|
39949
40513
|
}
|
|
39950
40514
|
async function createTaskList2(input, store, context) {
|
|
39951
40515
|
const timestamp = new Date().toISOString();
|
|
40516
|
+
const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
|
|
40517
|
+
if (!slug)
|
|
40518
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
39952
40519
|
return store.upsert("task_lists", {
|
|
39953
40520
|
id: randomUUID3(),
|
|
39954
40521
|
project_id: input.project_id ?? context?.projectId ?? null,
|
|
39955
|
-
slug
|
|
40522
|
+
slug,
|
|
39956
40523
|
name: input.name,
|
|
39957
40524
|
description: input.description ?? null,
|
|
39958
40525
|
metadata: input.metadata ?? {},
|
|
@@ -39964,9 +40531,20 @@ async function createTaskList2(input, store, context) {
|
|
|
39964
40531
|
}
|
|
39965
40532
|
async function updateTaskList2(id, input, store) {
|
|
39966
40533
|
const list = await requireRecord("task_lists", id, store);
|
|
40534
|
+
const patch = definedPatch(input);
|
|
40535
|
+
if (input.slug !== undefined) {
|
|
40536
|
+
const slug = slugifyRaw(input.slug);
|
|
40537
|
+
if (!slug)
|
|
40538
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
40539
|
+
const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === list.project_id && candidate.slug === slug);
|
|
40540
|
+
if (duplicate) {
|
|
40541
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
40542
|
+
}
|
|
40543
|
+
patch.slug = slug;
|
|
40544
|
+
}
|
|
39967
40545
|
return store.upsert("task_lists", {
|
|
39968
40546
|
...list,
|
|
39969
|
-
...
|
|
40547
|
+
...patch,
|
|
39970
40548
|
metadata: input.metadata ?? list.metadata,
|
|
39971
40549
|
updated_at: new Date().toISOString()
|
|
39972
40550
|
});
|
|
@@ -40050,6 +40628,16 @@ async function exportSnapshot(store) {
|
|
|
40050
40628
|
}
|
|
40051
40629
|
async function importSnapshot(snapshot, store, context) {
|
|
40052
40630
|
const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
|
|
40631
|
+
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
40632
|
+
if (result.errors.length > 0)
|
|
40633
|
+
return result;
|
|
40634
|
+
const [existingProjects, existingTaskLists] = await Promise.all([
|
|
40635
|
+
store.list("projects"),
|
|
40636
|
+
store.list("task_lists")
|
|
40637
|
+
]);
|
|
40638
|
+
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
40639
|
+
if (result.errors.length > 0)
|
|
40640
|
+
return result;
|
|
40053
40641
|
const entries = [
|
|
40054
40642
|
...snapshot.tasks.map((row) => ["tasks", row]),
|
|
40055
40643
|
...snapshot.projects.map((row) => ["projects", row]),
|
|
@@ -40120,10 +40708,7 @@ async function generateProjectPrefix(name, store) {
|
|
|
40120
40708
|
return candidate;
|
|
40121
40709
|
}
|
|
40122
40710
|
function slugifyRaw(value) {
|
|
40123
|
-
return value
|
|
40124
|
-
}
|
|
40125
|
-
function slugify2(value) {
|
|
40126
|
-
return slugifyRaw(value) || "todos";
|
|
40711
|
+
return normalizeSlug(value);
|
|
40127
40712
|
}
|
|
40128
40713
|
function normalizePlanSlug2(value) {
|
|
40129
40714
|
const slug = slugifyRaw(value);
|
|
@@ -40177,9 +40762,20 @@ function compareClock(left, right) {
|
|
|
40177
40762
|
function numberValue2(value) {
|
|
40178
40763
|
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
40179
40764
|
}
|
|
40765
|
+
function isPostgresUniqueViolation(error) {
|
|
40766
|
+
return typeof error === "object" && error !== null && error.code === "23505";
|
|
40767
|
+
}
|
|
40768
|
+
function postgresConstraintName(error) {
|
|
40769
|
+
if (typeof error !== "object" || error === null)
|
|
40770
|
+
return "";
|
|
40771
|
+
const candidate = error;
|
|
40772
|
+
const constraint = candidate.constraint ?? candidate.constraint_name;
|
|
40773
|
+
return typeof constraint === "string" ? constraint : "";
|
|
40774
|
+
}
|
|
40180
40775
|
var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_BY = "ORDER BY CASE payload->>'priority' WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END ASC, payload->>'created_at' ASC, payload->>'id' ASC";
|
|
40181
40776
|
var init_postgres_adapter = __esm(() => {
|
|
40182
40777
|
init_types();
|
|
40778
|
+
init_postgres_sync();
|
|
40183
40779
|
init_redaction();
|
|
40184
40780
|
});
|
|
40185
40781
|
|
|
@@ -40280,6 +40876,7 @@ function isCommentRedactionBackfillComplete(result) {
|
|
|
40280
40876
|
var COMMENT_REDACTION_BACKFILL_CONFIRMATION = "REDACT_STORED_TODOS_COMMENTS";
|
|
40281
40877
|
var init_comment_redaction_backfill = __esm(() => {
|
|
40282
40878
|
init_redaction();
|
|
40879
|
+
init_postgres_sync();
|
|
40283
40880
|
});
|
|
40284
40881
|
|
|
40285
40882
|
// src/server/cloud.ts
|
|
@@ -40293,6 +40890,7 @@ __export(exports_cloud, {
|
|
|
40293
40890
|
getCloudVerifier: () => getCloudVerifier,
|
|
40294
40891
|
getCloudStorageAdapter: () => getCloudStorageAdapter,
|
|
40295
40892
|
getApiKeyStore: () => getApiKeyStore,
|
|
40893
|
+
ensureCloudScopedSlugUniqueIndexes: () => ensureCloudScopedSlugUniqueIndexes,
|
|
40296
40894
|
ensureCloudSchema: () => ensureCloudSchema,
|
|
40297
40895
|
ensureCloudCommentCursorIndex: () => ensureCloudCommentCursorIndex,
|
|
40298
40896
|
closeCloud: () => closeCloud,
|
|
@@ -40377,6 +40975,9 @@ async function ensureCloudSchema() {
|
|
|
40377
40975
|
async function ensureCloudCommentCursorIndex() {
|
|
40378
40976
|
await getClient().query(postgresTodosCommentCursorIndexSql());
|
|
40379
40977
|
}
|
|
40978
|
+
async function ensureCloudScopedSlugUniqueIndexes() {
|
|
40979
|
+
await ensurePostgresScopedSlugUniqueIndexes(getClient());
|
|
40980
|
+
}
|
|
40380
40981
|
async function normalizeCloudPayloads() {
|
|
40381
40982
|
const client = getClient();
|
|
40382
40983
|
const res = await client.query(`UPDATE todos_sync_records
|
|
@@ -40408,6 +41009,7 @@ var init_cloud = __esm(() => {
|
|
|
40408
41009
|
init_auth();
|
|
40409
41010
|
init_cloud_client();
|
|
40410
41011
|
init_postgres_adapter();
|
|
41012
|
+
init_postgres_sync();
|
|
40411
41013
|
init_comment_redaction_backfill();
|
|
40412
41014
|
});
|
|
40413
41015
|
|
|
@@ -40432,6 +41034,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
40432
41034
|
schemas: {
|
|
40433
41035
|
Task: taskSchema,
|
|
40434
41036
|
Project: projectSchema,
|
|
41037
|
+
TaskList: taskListSchema,
|
|
40435
41038
|
TaskComment: taskCommentSchema,
|
|
40436
41039
|
CreateTaskInput: {
|
|
40437
41040
|
type: "object",
|
|
@@ -40460,12 +41063,65 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
40460
41063
|
},
|
|
40461
41064
|
CreateProjectInput: {
|
|
40462
41065
|
type: "object",
|
|
41066
|
+
additionalProperties: false,
|
|
40463
41067
|
required: ["name", "path"],
|
|
40464
41068
|
properties: {
|
|
41069
|
+
name: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
|
|
41070
|
+
path: { type: "string", minLength: 1 },
|
|
41071
|
+
description: { type: "string" },
|
|
41072
|
+
task_list_id: { type: "string", minLength: 1, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
|
|
41073
|
+
task_prefix: { type: "string", minLength: 1 }
|
|
41074
|
+
}
|
|
41075
|
+
},
|
|
41076
|
+
UpdateProjectInput: {
|
|
41077
|
+
type: "object",
|
|
41078
|
+
additionalProperties: false,
|
|
41079
|
+
minProperties: 1,
|
|
41080
|
+
properties: {
|
|
41081
|
+
name: { type: "string", minLength: 1 },
|
|
41082
|
+
path: { type: "string", minLength: 1 },
|
|
41083
|
+
description: { type: "string", nullable: true }
|
|
41084
|
+
}
|
|
41085
|
+
},
|
|
41086
|
+
RenameProjectInput: {
|
|
41087
|
+
type: "object",
|
|
41088
|
+
additionalProperties: false,
|
|
41089
|
+
required: ["new_slug"],
|
|
41090
|
+
properties: {
|
|
41091
|
+
new_slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
|
|
41092
|
+
name: { type: "string", minLength: 1 }
|
|
41093
|
+
}
|
|
41094
|
+
},
|
|
41095
|
+
ErrorResponse: {
|
|
41096
|
+
type: "object",
|
|
41097
|
+
required: ["error"],
|
|
41098
|
+
properties: {
|
|
41099
|
+
error: { type: "string" },
|
|
41100
|
+
code: { type: "string" },
|
|
41101
|
+
conflict: { type: "boolean" }
|
|
41102
|
+
}
|
|
41103
|
+
},
|
|
41104
|
+
CreateTaskListInput: {
|
|
41105
|
+
type: "object",
|
|
41106
|
+
additionalProperties: false,
|
|
41107
|
+
required: ["name"],
|
|
41108
|
+
properties: {
|
|
41109
|
+
name: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
|
|
41110
|
+
slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
|
|
41111
|
+
project_id: { type: "string" },
|
|
41112
|
+
description: { type: "string" },
|
|
41113
|
+
metadata: { type: "object", additionalProperties: true }
|
|
41114
|
+
}
|
|
41115
|
+
},
|
|
41116
|
+
UpdateTaskListInput: {
|
|
41117
|
+
type: "object",
|
|
41118
|
+
additionalProperties: false,
|
|
41119
|
+
minProperties: 1,
|
|
41120
|
+
properties: {
|
|
41121
|
+
slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
|
|
40465
41122
|
name: { type: "string" },
|
|
40466
|
-
path: { type: "string" },
|
|
40467
41123
|
description: { type: "string" },
|
|
40468
|
-
|
|
41124
|
+
metadata: { type: "object", additionalProperties: true }
|
|
40469
41125
|
}
|
|
40470
41126
|
},
|
|
40471
41127
|
CreateTaskCommentInput: {
|
|
@@ -40674,7 +41330,10 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
40674
41330
|
required: true,
|
|
40675
41331
|
content: { "application/json": { schema: { $ref: "#/components/schemas/CreateProjectInput" } } }
|
|
40676
41332
|
},
|
|
40677
|
-
responses: {
|
|
41333
|
+
responses: {
|
|
41334
|
+
"201": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } },
|
|
41335
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
41336
|
+
}
|
|
40678
41337
|
}
|
|
40679
41338
|
},
|
|
40680
41339
|
"/v1/projects/{id}": {
|
|
@@ -40683,6 +41342,86 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
40683
41342
|
summary: "Get a project by id",
|
|
40684
41343
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
40685
41344
|
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
|
|
41345
|
+
},
|
|
41346
|
+
patch: {
|
|
41347
|
+
operationId: "updateProject",
|
|
41348
|
+
summary: "Update a project",
|
|
41349
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
41350
|
+
requestBody: {
|
|
41351
|
+
required: true,
|
|
41352
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/UpdateProjectInput" } } }
|
|
41353
|
+
},
|
|
41354
|
+
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
|
|
41355
|
+
},
|
|
41356
|
+
delete: {
|
|
41357
|
+
operationId: "deleteProject",
|
|
41358
|
+
summary: "Delete a project",
|
|
41359
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
41360
|
+
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
|
|
41361
|
+
}
|
|
41362
|
+
},
|
|
41363
|
+
"/v1/projects/{id}/rename": {
|
|
41364
|
+
post: {
|
|
41365
|
+
operationId: "renameProject",
|
|
41366
|
+
summary: "Atomically rename a project and its canonical task list",
|
|
41367
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
41368
|
+
requestBody: {
|
|
41369
|
+
required: true,
|
|
41370
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/RenameProjectInput" } } }
|
|
41371
|
+
},
|
|
41372
|
+
responses: {
|
|
41373
|
+
"200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" }, task_lists_updated: { type: "number" } } } } } },
|
|
41374
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
41375
|
+
}
|
|
41376
|
+
}
|
|
41377
|
+
},
|
|
41378
|
+
"/v1/task-lists": {
|
|
41379
|
+
get: {
|
|
41380
|
+
operationId: "listTaskLists",
|
|
41381
|
+
summary: "List task lists",
|
|
41382
|
+
parameters: [{ name: "project_id", in: "query", schema: { type: "string" } }],
|
|
41383
|
+
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { task_lists: { type: "array", items: { $ref: "#/components/schemas/TaskList" } }, count: { type: "number" } } } } } } }
|
|
41384
|
+
},
|
|
41385
|
+
post: {
|
|
41386
|
+
operationId: "createTaskList",
|
|
41387
|
+
summary: "Create a task list",
|
|
41388
|
+
requestBody: {
|
|
41389
|
+
required: true,
|
|
41390
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/CreateTaskListInput" } } }
|
|
41391
|
+
},
|
|
41392
|
+
responses: {
|
|
41393
|
+
"201": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } },
|
|
41394
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
41395
|
+
}
|
|
41396
|
+
}
|
|
41397
|
+
},
|
|
41398
|
+
"/v1/task-lists/{id}": {
|
|
41399
|
+
get: {
|
|
41400
|
+
operationId: "getTaskList",
|
|
41401
|
+
summary: "Get a task list by id",
|
|
41402
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
41403
|
+
responses: {
|
|
41404
|
+
"200": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } }
|
|
41405
|
+
}
|
|
41406
|
+
},
|
|
41407
|
+
patch: {
|
|
41408
|
+
operationId: "updateTaskList",
|
|
41409
|
+
summary: "Update a task list",
|
|
41410
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
41411
|
+
requestBody: {
|
|
41412
|
+
required: true,
|
|
41413
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/UpdateTaskListInput" } } }
|
|
41414
|
+
},
|
|
41415
|
+
responses: {
|
|
41416
|
+
"200": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } },
|
|
41417
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
41418
|
+
}
|
|
41419
|
+
},
|
|
41420
|
+
delete: {
|
|
41421
|
+
operationId: "deleteTaskList",
|
|
41422
|
+
summary: "Delete a task list",
|
|
41423
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
41424
|
+
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
|
|
40686
41425
|
}
|
|
40687
41426
|
},
|
|
40688
41427
|
"/v1/stats": {
|
|
@@ -40749,7 +41488,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
40749
41488
|
}
|
|
40750
41489
|
};
|
|
40751
41490
|
}
|
|
40752
|
-
var taskSchema, projectSchema, taskCommentSchema;
|
|
41491
|
+
var taskSchema, projectSchema, taskListSchema, taskCommentSchema;
|
|
40753
41492
|
var init_openapi = __esm(() => {
|
|
40754
41493
|
init_package_version();
|
|
40755
41494
|
taskSchema = {
|
|
@@ -40776,6 +41515,22 @@ var init_openapi = __esm(() => {
|
|
|
40776
41515
|
name: { type: "string" },
|
|
40777
41516
|
path: { type: "string" },
|
|
40778
41517
|
description: { type: "string", nullable: true },
|
|
41518
|
+
task_list_id: { type: "string", nullable: true },
|
|
41519
|
+
task_prefix: { type: "string", nullable: true },
|
|
41520
|
+
task_counter: { type: "number" },
|
|
41521
|
+
created_at: { type: "string" },
|
|
41522
|
+
updated_at: { type: "string" }
|
|
41523
|
+
}
|
|
41524
|
+
};
|
|
41525
|
+
taskListSchema = {
|
|
41526
|
+
type: "object",
|
|
41527
|
+
properties: {
|
|
41528
|
+
id: { type: "string" },
|
|
41529
|
+
project_id: { type: "string", nullable: true },
|
|
41530
|
+
slug: { type: "string" },
|
|
41531
|
+
name: { type: "string" },
|
|
41532
|
+
description: { type: "string", nullable: true },
|
|
41533
|
+
metadata: { type: "object", additionalProperties: true },
|
|
40779
41534
|
created_at: { type: "string" },
|
|
40780
41535
|
updated_at: { type: "string" }
|
|
40781
41536
|
}
|
|
@@ -40809,6 +41564,48 @@ function json2(body, status = 200) {
|
|
|
40809
41564
|
function error(status, message, extra) {
|
|
40810
41565
|
return json2({ error: message, ...extra ?? {} }, status);
|
|
40811
41566
|
}
|
|
41567
|
+
function validateProjectPatch(value) {
|
|
41568
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
41569
|
+
return { ok: false, message: "project patch must be an object" };
|
|
41570
|
+
const body = value;
|
|
41571
|
+
const allowed = new Set(["name", "path", "description"]);
|
|
41572
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
41573
|
+
if (unknown)
|
|
41574
|
+
return { ok: false, message: `unknown project field: ${unknown}` };
|
|
41575
|
+
if (Object.keys(body).length === 0)
|
|
41576
|
+
return { ok: false, message: "project patch must not be empty" };
|
|
41577
|
+
if (body["name"] !== undefined && (typeof body["name"] !== "string" || !body["name"].trim()))
|
|
41578
|
+
return { ok: false, message: "name must be a non-empty string" };
|
|
41579
|
+
if (body["path"] !== undefined && (typeof body["path"] !== "string" || !body["path"].trim()))
|
|
41580
|
+
return { ok: false, message: "path must be a non-empty string" };
|
|
41581
|
+
if (body["description"] !== undefined && body["description"] !== null && typeof body["description"] !== "string")
|
|
41582
|
+
return { ok: false, message: "description must be a string or null" };
|
|
41583
|
+
return { ok: true, patch: body };
|
|
41584
|
+
}
|
|
41585
|
+
function validateProjectCreate(value) {
|
|
41586
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
41587
|
+
return { ok: false, message: "project body must be an object" };
|
|
41588
|
+
const body = value;
|
|
41589
|
+
const allowed = new Set(["name", "path", "description", "task_list_id", "task_prefix"]);
|
|
41590
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
41591
|
+
if (unknown)
|
|
41592
|
+
return { ok: false, message: `unknown project field: ${unknown}` };
|
|
41593
|
+
if (typeof body["name"] !== "string" || !body["name"].trim())
|
|
41594
|
+
return { ok: false, message: "name must be a non-empty string" };
|
|
41595
|
+
if (!normalizeSlug(body["name"]))
|
|
41596
|
+
return { ok: false, message: "name must produce a non-empty canonical slug" };
|
|
41597
|
+
if (typeof body["path"] !== "string" || !body["path"].trim())
|
|
41598
|
+
return { ok: false, message: "path must be a non-empty string" };
|
|
41599
|
+
if (body["description"] !== undefined && typeof body["description"] !== "string")
|
|
41600
|
+
return { ok: false, message: "description must be a string" };
|
|
41601
|
+
if (body["task_list_id"] !== undefined && !isCanonicalSlug(body["task_list_id"])) {
|
|
41602
|
+
return { ok: false, message: "task_list_id must be non-empty canonical kebab-case" };
|
|
41603
|
+
}
|
|
41604
|
+
if (body["task_prefix"] !== undefined && (typeof body["task_prefix"] !== "string" || !body["task_prefix"].trim())) {
|
|
41605
|
+
return { ok: false, message: "task_prefix must be a non-empty string" };
|
|
41606
|
+
}
|
|
41607
|
+
return { ok: true, input: body };
|
|
41608
|
+
}
|
|
40812
41609
|
async function readJson(req) {
|
|
40813
41610
|
try {
|
|
40814
41611
|
const text = await req.text();
|
|
@@ -41289,14 +42086,31 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
41289
42086
|
}
|
|
41290
42087
|
if (method === "POST") {
|
|
41291
42088
|
const body = await readJson(req);
|
|
41292
|
-
if (!body
|
|
41293
|
-
return error(400, "
|
|
41294
|
-
|
|
41295
|
-
|
|
42089
|
+
if (!body)
|
|
42090
|
+
return error(400, "invalid JSON body");
|
|
42091
|
+
const validated = validateProjectCreate(body);
|
|
42092
|
+
if (!validated.ok)
|
|
42093
|
+
return error(400, validated.message);
|
|
42094
|
+
const project = await store.projects.create(validated.input, contextFromPrincipal(principal));
|
|
41296
42095
|
return json2({ project }, 201);
|
|
41297
42096
|
}
|
|
41298
42097
|
return error(405, `method ${method} not allowed on /v1/projects`);
|
|
41299
42098
|
}
|
|
42099
|
+
if (action === "rename") {
|
|
42100
|
+
if (method !== "POST")
|
|
42101
|
+
return error(405, `method ${method} not allowed on /v1/projects/:id/rename`);
|
|
42102
|
+
const body = await readJson(req);
|
|
42103
|
+
if (!body || typeof body.new_slug !== "string" || !body.new_slug.trim() || !normalizeSlug(body.new_slug)) {
|
|
42104
|
+
return error(400, "new_slug must be a non-empty string");
|
|
42105
|
+
}
|
|
42106
|
+
if (body.name !== undefined && (typeof body.name !== "string" || !body.name.trim())) {
|
|
42107
|
+
return error(400, "name must be a non-empty string");
|
|
42108
|
+
}
|
|
42109
|
+
const unknownField = Object.keys(body).find((key) => !["new_slug", "name"].includes(key));
|
|
42110
|
+
if (unknownField)
|
|
42111
|
+
return error(400, `unknown project rename field: ${unknownField}`);
|
|
42112
|
+
return json2(await store.projects.rename(id, body, contextFromPrincipal(principal)));
|
|
42113
|
+
}
|
|
41300
42114
|
if (method === "GET") {
|
|
41301
42115
|
const project = await store.projects.get(id);
|
|
41302
42116
|
return project ? json2({ project }) : error(404, "project not found");
|
|
@@ -41305,8 +42119,13 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
41305
42119
|
const body = await readJson(req);
|
|
41306
42120
|
if (!body)
|
|
41307
42121
|
return error(400, "invalid JSON body");
|
|
41308
|
-
const
|
|
41309
|
-
|
|
42122
|
+
const validated = validateProjectPatch(body);
|
|
42123
|
+
if (!validated.ok)
|
|
42124
|
+
return error(400, validated.message);
|
|
42125
|
+
if (!await store.projects.get(id))
|
|
42126
|
+
return error(404, "project not found");
|
|
42127
|
+
const project = await store.projects.update(id, validated.patch);
|
|
42128
|
+
return json2({ project });
|
|
41310
42129
|
}
|
|
41311
42130
|
if (method === "DELETE") {
|
|
41312
42131
|
await store.projects.delete(id, contextFromPrincipal(principal));
|
|
@@ -41393,6 +42212,21 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
41393
42212
|
const body = await readJson(req);
|
|
41394
42213
|
if (!body || typeof body.name !== "string" || !body.name.trim())
|
|
41395
42214
|
return error(400, "name is required");
|
|
42215
|
+
const unknownField = Object.keys(body).find((key) => !["name", "slug", "project_id", "description", "metadata"].includes(key));
|
|
42216
|
+
if (unknownField)
|
|
42217
|
+
return error(400, `unsupported task-list create field: ${unknownField}`);
|
|
42218
|
+
if (body.slug !== undefined && typeof body.slug !== "string")
|
|
42219
|
+
return error(400, "slug must be a string");
|
|
42220
|
+
if (body.project_id !== undefined && (typeof body.project_id !== "string" || !body.project_id.trim()))
|
|
42221
|
+
return error(400, "project_id must be a non-empty string");
|
|
42222
|
+
if (body.description !== undefined && typeof body.description !== "string")
|
|
42223
|
+
return error(400, "description must be a string");
|
|
42224
|
+
if (body.metadata !== undefined && (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
|
|
42225
|
+
return error(400, "metadata must be an object");
|
|
42226
|
+
}
|
|
42227
|
+
if (!normalizeSlug(body.slug === undefined ? body.name : body.slug)) {
|
|
42228
|
+
return error(400, "task-list slug must be non-empty kebab-case");
|
|
42229
|
+
}
|
|
41396
42230
|
const taskList = await store.taskLists.create(body, contextFromPrincipal(principal));
|
|
41397
42231
|
return json2({ task_list: taskList }, 201);
|
|
41398
42232
|
}
|
|
@@ -41400,6 +42234,29 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
41400
42234
|
const taskList = await store.taskLists.get(id);
|
|
41401
42235
|
return taskList ? json2({ task_list: taskList }) : error(404, "task list not found");
|
|
41402
42236
|
}
|
|
42237
|
+
if (id && (method === "PATCH" || method === "PUT")) {
|
|
42238
|
+
const body = await readJson(req);
|
|
42239
|
+
if (!body)
|
|
42240
|
+
return error(400, "invalid JSON body");
|
|
42241
|
+
const unknownField = Object.keys(body).find((key) => !["slug", "name", "description", "metadata"].includes(key));
|
|
42242
|
+
if (unknownField)
|
|
42243
|
+
return error(400, `unsupported task-list update field: ${unknownField}`);
|
|
42244
|
+
if (Object.keys(body).length === 0)
|
|
42245
|
+
return error(400, "task-list update must not be empty");
|
|
42246
|
+
if (body.slug !== undefined && (typeof body.slug !== "string" || !normalizeSlug(body.slug)))
|
|
42247
|
+
return error(400, "slug must be a non-empty string");
|
|
42248
|
+
if (body.name !== undefined && (typeof body.name !== "string" || !body.name.trim()))
|
|
42249
|
+
return error(400, "name must be a non-empty string");
|
|
42250
|
+
if (body.description !== undefined && typeof body.description !== "string")
|
|
42251
|
+
return error(400, "description must be a string");
|
|
42252
|
+
if (body.metadata !== undefined && (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
|
|
42253
|
+
return error(400, "metadata must be an object");
|
|
42254
|
+
}
|
|
42255
|
+
if (!await store.taskLists.get(id))
|
|
42256
|
+
return error(404, "task list not found");
|
|
42257
|
+
const taskList = await store.taskLists.update(id, body);
|
|
42258
|
+
return json2({ task_list: taskList });
|
|
42259
|
+
}
|
|
41403
42260
|
if (id && method === "DELETE") {
|
|
41404
42261
|
const deleted = await store.taskLists.delete(id, contextFromPrincipal(principal));
|
|
41405
42262
|
return deleted ? json2({ deleted: true, id }) : error(404, "task list not found");
|
|
@@ -41472,6 +42329,10 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
41472
42329
|
} catch (e) {
|
|
41473
42330
|
if (e instanceof LockError)
|
|
41474
42331
|
return error(409, e.message, { code: LockError.code });
|
|
42332
|
+
if (e instanceof ResourceConflictError)
|
|
42333
|
+
return error(409, e.message, { code: e.code, conflict: true });
|
|
42334
|
+
if (e instanceof ProjectNotFoundError)
|
|
42335
|
+
return error(404, e.message, { code: ProjectNotFoundError.code });
|
|
41475
42336
|
return error(500, e.message || "internal error");
|
|
41476
42337
|
}
|
|
41477
42338
|
}
|
|
@@ -79617,6 +80478,7 @@ function createHybridTodosStorageAdapter(options) {
|
|
|
79617
80478
|
};
|
|
79618
80479
|
}
|
|
79619
80480
|
var init_hybrid = __esm(() => {
|
|
80481
|
+
init_postgres_sync();
|
|
79620
80482
|
init_local_sqlite();
|
|
79621
80483
|
});
|
|
79622
80484
|
|
|
@@ -79874,6 +80736,19 @@ function createShadowTodosStorageAdapter(options) {
|
|
|
79874
80736
|
mirror.enqueueUpsert("projects", project, context);
|
|
79875
80737
|
return project;
|
|
79876
80738
|
},
|
|
80739
|
+
async rename(id, input, context) {
|
|
80740
|
+
const projectBefore = await local.projects.get(id, context);
|
|
80741
|
+
const cascadeCandidates = projectBefore?.task_list_id ? (await local.taskLists.list(id, context)).filter((list) => list.slug === projectBefore.task_list_id) : [];
|
|
80742
|
+
const result = await local.projects.rename(id, input, context);
|
|
80743
|
+
mirror.enqueueUpsert("projects", result.project, context);
|
|
80744
|
+
for (const candidate of cascadeCandidates) {
|
|
80745
|
+
const changed = await local.taskLists.get(candidate.id, context);
|
|
80746
|
+
if (changed && (changed.slug !== candidate.slug || changed.name !== candidate.name)) {
|
|
80747
|
+
mirror.enqueueUpsert("taskLists", changed, context);
|
|
80748
|
+
}
|
|
80749
|
+
}
|
|
80750
|
+
return result;
|
|
80751
|
+
},
|
|
79877
80752
|
async delete(id, context) {
|
|
79878
80753
|
const deleted = await local.projects.delete(id, context);
|
|
79879
80754
|
if (deleted)
|
|
@@ -79976,6 +80851,7 @@ function isAgent(value) {
|
|
|
79976
80851
|
var SNAPSHOT_TO_OBJECT_TYPE;
|
|
79977
80852
|
var init_shadow = __esm(() => {
|
|
79978
80853
|
init_local_sqlite();
|
|
80854
|
+
init_postgres_sync();
|
|
79979
80855
|
SNAPSHOT_TO_OBJECT_TYPE = {
|
|
79980
80856
|
tasks: "tasks",
|
|
79981
80857
|
projects: "projects",
|
|
@@ -80491,6 +81367,9 @@ __export(exports_storage, {
|
|
|
80491
81367
|
signAwsV4Request: () => signAwsV4Request,
|
|
80492
81368
|
registerShadowExitFlush: () => registerShadowExitFlush,
|
|
80493
81369
|
postgresTodosSyncSchemaSql: () => postgresTodosSyncSchemaSql,
|
|
81370
|
+
postgresTodosScopedSlugUniqueIndexSql: () => postgresTodosScopedSlugUniqueIndexSql,
|
|
81371
|
+
postgresTodosScopedSlugPreflightSql: () => postgresTodosScopedSlugPreflightSql,
|
|
81372
|
+
postgresTodosScopedSlugIndexStatusSql: () => postgresTodosScopedSlugIndexStatusSql,
|
|
80494
81373
|
postgresTodosCommentCursorIndexSql: () => postgresTodosCommentCursorIndexSql,
|
|
80495
81374
|
planRunArtifactsS3Sync: () => planRunArtifactsS3Sync,
|
|
80496
81375
|
parseStorageMode: () => parseStorageMode,
|
|
@@ -80513,6 +81392,7 @@ __export(exports_storage, {
|
|
|
80513
81392
|
getRuntimeShadowOutbox: () => getRuntimeShadowOutbox,
|
|
80514
81393
|
getCanonicalTodosRdsConfig: () => getCanonicalTodosRdsConfig,
|
|
80515
81394
|
exportSqliteTodosStorageSnapshot: () => exportSqliteTodosStorageSnapshot,
|
|
81395
|
+
ensurePostgresScopedSlugUniqueIndexes: () => ensurePostgresScopedSlugUniqueIndexes,
|
|
80516
81396
|
downloadRunArtifactsFromS3: () => downloadRunArtifactsFromS3,
|
|
80517
81397
|
createTodosStorageAdapter: () => createTodosStorageAdapter,
|
|
80518
81398
|
createTodosShadowOutbox: () => createTodosShadowOutbox,
|
|
@@ -80538,6 +81418,8 @@ __export(exports_storage, {
|
|
|
80538
81418
|
STORAGE_TABLES: () => STORAGE_TABLES,
|
|
80539
81419
|
SHADOW_TRIGGER_TABLES: () => SHADOW_TRIGGER_TABLES,
|
|
80540
81420
|
PostgresTodosSyncStore: () => PostgresTodosSyncStore,
|
|
81421
|
+
PostgresScopedSlugMigrationConflictError: () => PostgresScopedSlugMigrationConflictError,
|
|
81422
|
+
PostgresScopedSlugIndexBuildError: () => PostgresScopedSlugIndexBuildError,
|
|
80541
81423
|
DEFAULT_TODOS_POSTGRES_SYNC_TABLE: () => DEFAULT_TODOS_POSTGRES_SYNC_TABLE,
|
|
80542
81424
|
DEFAULT_TODOS_POSTGRES_CURSOR_TABLE: () => DEFAULT_TODOS_POSTGRES_CURSOR_TABLE,
|
|
80543
81425
|
COMMENT_REDACTION_BACKFILL_CONFIRMATION: () => COMMENT_REDACTION_BACKFILL_CONFIRMATION,
|
|
@@ -80555,6 +81437,7 @@ var init_storage2 = __esm(() => {
|
|
|
80555
81437
|
init_hybrid();
|
|
80556
81438
|
init_local_sqlite();
|
|
80557
81439
|
init_sqlite_snapshot();
|
|
81440
|
+
init_postgres_sync();
|
|
80558
81441
|
init_comment_redaction_backfill();
|
|
80559
81442
|
init_postgres_adapter();
|
|
80560
81443
|
init_s3_artifacts();
|