@hasna/todos 0.11.88 → 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 +10 -2
- 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 +950 -118
- 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
|
@@ -8876,9 +8876,8 @@ function cloudProjectPathBasename(value) {
|
|
|
8876
8876
|
function uniqueProjectMatches(projects, predicate) {
|
|
8877
8877
|
return [...new Map(projects.filter(predicate).map((project) => [project.id, project])).values()];
|
|
8878
8878
|
}
|
|
8879
|
-
|
|
8879
|
+
function resolveCloudProjectRef(projects, ref) {
|
|
8880
8880
|
const input = ref.trim();
|
|
8881
|
-
const projects = await cloudListProjects(client);
|
|
8882
8881
|
const normalizedRef = input.toLowerCase();
|
|
8883
8882
|
const pathLike = input.startsWith(".") || input.includes("/") || input.includes("\\");
|
|
8884
8883
|
const normalizedPath = pathLike ? resolvePath(input) : undefined;
|
|
@@ -8898,6 +8897,9 @@ async function cloudResolveProjectRef(client, ref) {
|
|
|
8898
8897
|
}
|
|
8899
8898
|
throw new Error(`Project not found: "${input}"`);
|
|
8900
8899
|
}
|
|
8900
|
+
async function cloudResolveProjectRef(client, ref) {
|
|
8901
|
+
return resolveCloudProjectRef(await cloudListProjects(client), ref);
|
|
8902
|
+
}
|
|
8901
8903
|
async function cloudListPlans(client, projectId) {
|
|
8902
8904
|
const query = projectId ? { project_id: projectId } : {};
|
|
8903
8905
|
const res = await client.list("plans", { query });
|
|
@@ -9232,6 +9234,13 @@ async function cloudCreateTaskList(client, input) {
|
|
|
9232
9234
|
}
|
|
9233
9235
|
return raw;
|
|
9234
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
|
+
}
|
|
9235
9244
|
async function cloudDeleteTaskList(client, id) {
|
|
9236
9245
|
await client.delete("task-lists", id);
|
|
9237
9246
|
return true;
|
|
@@ -11076,6 +11085,95 @@ function ensureSchema(db) {
|
|
|
11076
11085
|
)`);
|
|
11077
11086
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_project ON project_machine_paths(project_id)");
|
|
11078
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`);
|
|
11079
11177
|
ensureTable("storage_tombstones", `
|
|
11080
11178
|
CREATE TABLE storage_tombstones (
|
|
11081
11179
|
id TEXT PRIMARY KEY,
|
|
@@ -12489,7 +12587,7 @@ var init_database = __esm(() => {
|
|
|
12489
12587
|
});
|
|
12490
12588
|
|
|
12491
12589
|
// src/types/index.ts
|
|
12492
|
-
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;
|
|
12493
12591
|
var init_types = __esm(() => {
|
|
12494
12592
|
TASK_STATUSES = [
|
|
12495
12593
|
"pending",
|
|
@@ -12538,6 +12636,14 @@ var init_types = __esm(() => {
|
|
|
12538
12636
|
this.name = "ProjectNotFoundError";
|
|
12539
12637
|
}
|
|
12540
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
|
+
};
|
|
12541
12647
|
PlanNotFoundError = class PlanNotFoundError extends Error {
|
|
12542
12648
|
planId;
|
|
12543
12649
|
static code = "PLAN_NOT_FOUND";
|
|
@@ -12690,6 +12796,91 @@ var init_storage_tombstones = __esm(() => {
|
|
|
12690
12796
|
init_machines();
|
|
12691
12797
|
});
|
|
12692
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
|
+
|
|
12693
12884
|
// src/db/projects.ts
|
|
12694
12885
|
var exports_projects = {};
|
|
12695
12886
|
__export(exports_projects, {
|
|
@@ -12713,7 +12904,7 @@ __export(exports_projects, {
|
|
|
12713
12904
|
addProjectSource: () => addProjectSource
|
|
12714
12905
|
});
|
|
12715
12906
|
function slugify(name) {
|
|
12716
|
-
return name
|
|
12907
|
+
return normalizeSlug(name);
|
|
12717
12908
|
}
|
|
12718
12909
|
function generatePrefix(name, db) {
|
|
12719
12910
|
const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
|
|
@@ -12737,14 +12928,23 @@ function generatePrefix(name, db) {
|
|
|
12737
12928
|
}
|
|
12738
12929
|
function createProject(input, db) {
|
|
12739
12930
|
const d = db || getDatabase();
|
|
12740
|
-
|
|
12741
|
-
|
|
12742
|
-
|
|
12743
|
-
|
|
12744
|
-
|
|
12745
|
-
|
|
12746
|
-
|
|
12747
|
-
|
|
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
|
+
})();
|
|
12748
12948
|
}
|
|
12749
12949
|
function getProject(id, db) {
|
|
12750
12950
|
const d = db || getDatabase();
|
|
@@ -12772,6 +12972,9 @@ function updateProject(id, input, db) {
|
|
|
12772
12972
|
const project = getProject(id, d);
|
|
12773
12973
|
if (!project)
|
|
12774
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
|
+
}
|
|
12775
12978
|
const sets = ["updated_at = ?"];
|
|
12776
12979
|
const params = [now()];
|
|
12777
12980
|
if (input.name !== undefined) {
|
|
@@ -12782,10 +12985,6 @@ function updateProject(id, input, db) {
|
|
|
12782
12985
|
sets.push("description = ?");
|
|
12783
12986
|
params.push(input.description);
|
|
12784
12987
|
}
|
|
12785
|
-
if (input.task_list_id !== undefined) {
|
|
12786
|
-
sets.push("task_list_id = ?");
|
|
12787
|
-
params.push(input.task_list_id);
|
|
12788
|
-
}
|
|
12789
12988
|
if (input.path !== undefined) {
|
|
12790
12989
|
sets.push("path = ?");
|
|
12791
12990
|
params.push(input.path);
|
|
@@ -12796,29 +12995,41 @@ function updateProject(id, input, db) {
|
|
|
12796
12995
|
}
|
|
12797
12996
|
function renameProject(id, input, db) {
|
|
12798
12997
|
const d = db || getDatabase();
|
|
12799
|
-
|
|
12800
|
-
|
|
12801
|
-
|
|
12802
|
-
|
|
12803
|
-
|
|
12804
|
-
|
|
12805
|
-
|
|
12806
|
-
|
|
12807
|
-
|
|
12808
|
-
|
|
12809
|
-
|
|
12810
|
-
|
|
12811
|
-
|
|
12812
|
-
|
|
12813
|
-
|
|
12814
|
-
|
|
12815
|
-
|
|
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
|
+
}
|
|
12816
13027
|
}
|
|
12817
|
-
|
|
12818
|
-
|
|
12819
|
-
|
|
12820
|
-
|
|
12821
|
-
|
|
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
|
+
})();
|
|
12822
13033
|
}
|
|
12823
13034
|
function deleteProject(id, db) {
|
|
12824
13035
|
const d = db || getDatabase();
|
|
@@ -12830,8 +13041,10 @@ function deleteProject(id, db) {
|
|
|
12830
13041
|
object_id: id,
|
|
12831
13042
|
payload: project
|
|
12832
13043
|
}, d);
|
|
12833
|
-
|
|
12834
|
-
|
|
13044
|
+
return d.transaction(() => {
|
|
13045
|
+
releaseCanonicalSlugClaims("project", id, d);
|
|
13046
|
+
return d.run("DELETE FROM projects WHERE id = ?", [id]).changes > 0;
|
|
13047
|
+
})();
|
|
12835
13048
|
}
|
|
12836
13049
|
function rowToSource(row) {
|
|
12837
13050
|
return {
|
|
@@ -14519,19 +14732,22 @@ function rowToTaskList(row) {
|
|
|
14519
14732
|
}
|
|
14520
14733
|
function createTaskList(input, db) {
|
|
14521
14734
|
const d = db || getDatabase();
|
|
14522
|
-
|
|
14523
|
-
|
|
14524
|
-
|
|
14525
|
-
|
|
14526
|
-
|
|
14527
|
-
|
|
14528
|
-
|
|
14529
|
-
|
|
14530
|
-
|
|
14531
|
-
|
|
14532
|
-
|
|
14533
|
-
|
|
14534
|
-
|
|
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
|
+
})();
|
|
14535
14751
|
}
|
|
14536
14752
|
function getTaskList(id, db) {
|
|
14537
14753
|
const d = db || getDatabase();
|
|
@@ -14557,26 +14773,45 @@ function listTaskLists(projectId, db) {
|
|
|
14557
14773
|
}
|
|
14558
14774
|
function updateTaskList(id, input, db) {
|
|
14559
14775
|
const d = db || getDatabase();
|
|
14560
|
-
|
|
14561
|
-
|
|
14562
|
-
|
|
14563
|
-
|
|
14564
|
-
|
|
14565
|
-
|
|
14566
|
-
|
|
14567
|
-
|
|
14568
|
-
|
|
14569
|
-
|
|
14570
|
-
|
|
14571
|
-
|
|
14572
|
-
|
|
14573
|
-
|
|
14574
|
-
|
|
14575
|
-
|
|
14576
|
-
|
|
14577
|
-
|
|
14578
|
-
|
|
14579
|
-
|
|
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
|
+
})();
|
|
14580
14815
|
}
|
|
14581
14816
|
function deleteTaskList(id, db) {
|
|
14582
14817
|
const d = db || getDatabase();
|
|
@@ -14588,7 +14823,10 @@ function deleteTaskList(id, db) {
|
|
|
14588
14823
|
object_id: id,
|
|
14589
14824
|
payload: list
|
|
14590
14825
|
}, d);
|
|
14591
|
-
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
|
+
})();
|
|
14592
14830
|
}
|
|
14593
14831
|
function ensureTaskList(name, slug, projectId, db) {
|
|
14594
14832
|
const d = db || getDatabase();
|
|
@@ -20580,7 +20818,8 @@ function registerTaskCommands(program2) {
|
|
|
20580
20818
|
if (cloud) {
|
|
20581
20819
|
let task3;
|
|
20582
20820
|
try {
|
|
20583
|
-
const
|
|
20821
|
+
const cloudProjectRef = opts.project || globalOpts.project;
|
|
20822
|
+
const cloudProjectId = cloudProjectRef ? await cloudResolveProjectRef(cloud, cloudProjectRef) : undefined;
|
|
20584
20823
|
const cloudTaskListId = opts.list ? await cloudResolveTaskListRef(cloud, opts.list, cloudProjectId) : undefined;
|
|
20585
20824
|
if (opts.list && !cloudTaskListId) {
|
|
20586
20825
|
throw new Error(`Could not resolve task list ID or slug: ${opts.list}`);
|
|
@@ -24052,10 +24291,10 @@ function bootstrapProject(options = {}, db) {
|
|
|
24052
24291
|
let project = ensureProject(options.name || discovery.projectName, discovery.projectPath, d);
|
|
24053
24292
|
const createdProject = !beforeProject;
|
|
24054
24293
|
if (project.task_list_id !== taskListSlug || options.name && project.name !== options.name) {
|
|
24055
|
-
project =
|
|
24294
|
+
project = renameProject(project.id, {
|
|
24056
24295
|
name: options.name ?? project.name,
|
|
24057
|
-
|
|
24058
|
-
}, d);
|
|
24296
|
+
new_slug: taskListSlug
|
|
24297
|
+
}, d).project;
|
|
24059
24298
|
}
|
|
24060
24299
|
setMachineLocalPath(project.id, discovery.projectPath, d);
|
|
24061
24300
|
const beforeTaskList = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ?").get(project.id, taskListSlug);
|
|
@@ -32397,7 +32636,7 @@ function registerProjectCommands(program2) {
|
|
|
32397
32636
|
if (existing) {
|
|
32398
32637
|
project = existing;
|
|
32399
32638
|
if (opts.taskListId) {
|
|
32400
|
-
project =
|
|
32639
|
+
project = renameProject(existing.id, { new_slug: opts.taskListId }).project;
|
|
32401
32640
|
}
|
|
32402
32641
|
} else {
|
|
32403
32642
|
project = createProject({ name, path: projectPath, task_list_id: opts.taskListId });
|
|
@@ -32462,18 +32701,23 @@ function registerProjectCommands(program2) {
|
|
|
32462
32701
|
const globalOpts = program2.opts();
|
|
32463
32702
|
const useJson = opts.json || globalOpts.json;
|
|
32464
32703
|
try {
|
|
32465
|
-
const
|
|
32466
|
-
|
|
32467
|
-
|
|
32468
|
-
|
|
32469
|
-
|
|
32470
|
-
|
|
32471
|
-
|
|
32472
|
-
|
|
32473
|
-
|
|
32474
|
-
|
|
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 });
|
|
32475
32720
|
}
|
|
32476
|
-
const result = renameProject2(resolvedId, { name: opts.name, new_slug: newSlug });
|
|
32477
32721
|
if (useJson) {
|
|
32478
32722
|
output({ project: result.project, task_lists_updated: result.task_lists_updated }, true);
|
|
32479
32723
|
} else {
|
|
@@ -33828,7 +34072,7 @@ ${isOnline ? chalk5.green("\u25CF") : chalk5.dim("\u25CB")} ${chalk5.bold(agent.
|
|
|
33828
34072
|
try {
|
|
33829
34073
|
const globalOpts = program2.opts();
|
|
33830
34074
|
const cloud = getTodosCloudClient();
|
|
33831
|
-
const projectId = cloud ? globalOpts.project : autoProject(globalOpts);
|
|
34075
|
+
const projectId = cloud ? globalOpts.project ? await cloudResolveProjectRef(cloud, globalOpts.project) : undefined : autoProject(globalOpts);
|
|
33832
34076
|
if (opts.add) {
|
|
33833
34077
|
const input = { name: opts.add, slug: opts.slug, description: opts.description, project_id: projectId };
|
|
33834
34078
|
const list = cloud ? await cloudCreateTaskList(cloud, input) : createTaskList(input);
|
|
@@ -37762,6 +38006,14 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
37762
38006
|
skipped: 0,
|
|
37763
38007
|
errors: []
|
|
37764
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;
|
|
37765
38017
|
const applyRows = (objectType3, table, columns, rows, updateClockColumn, afterUpsert) => {
|
|
37766
38018
|
for (const row of rows) {
|
|
37767
38019
|
try {
|
|
@@ -38134,6 +38386,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
38134
38386
|
getByPath: (path) => getProjectByPath(path, database()),
|
|
38135
38387
|
list: () => listProjects(database()),
|
|
38136
38388
|
update: (id, input) => updateProject(id, input, database()),
|
|
38389
|
+
rename: (id, input) => renameProject(id, input, database()),
|
|
38137
38390
|
delete: (id) => deleteProject(id, database())
|
|
38138
38391
|
},
|
|
38139
38392
|
plans: {
|
|
@@ -38241,6 +38494,91 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
38241
38494
|
)`
|
|
38242
38495
|
];
|
|
38243
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
|
+
}
|
|
38244
38582
|
function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
38245
38583
|
assertSafeIdentifier(tableName);
|
|
38246
38584
|
return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
|
|
@@ -38269,9 +38607,31 @@ class PostgresTodosSyncStore {
|
|
|
38269
38607
|
}
|
|
38270
38608
|
}
|
|
38271
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
|
+
}
|
|
38272
38630
|
const result = { records: 0, objectTypes: {} };
|
|
38273
38631
|
const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
|
|
38274
38632
|
for (const entry of snapshotEntries(snapshot)) {
|
|
38633
|
+
if (entry.deletedAt === null)
|
|
38634
|
+
assertCanonicalScopedSlugEntry(entry);
|
|
38275
38635
|
await this.client.query(`INSERT INTO ${this.tableName} (
|
|
38276
38636
|
service, object_type, object_id, payload, updated_at,
|
|
38277
38637
|
deleted_at, source_machine_id, version
|
|
@@ -38350,6 +38710,22 @@ function snapshotEntries(snapshot) {
|
|
|
38350
38710
|
}))
|
|
38351
38711
|
];
|
|
38352
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
|
+
}
|
|
38353
38729
|
function entry(type, payload, fallbackUpdatedAt) {
|
|
38354
38730
|
const id = payload["id"];
|
|
38355
38731
|
if (typeof id !== "string" || !id)
|
|
@@ -38432,7 +38808,26 @@ function assertSafeIdentifier(value) {
|
|
|
38432
38808
|
if (!/^[a-z_][a-z0-9_]*$/i.test(value))
|
|
38433
38809
|
throw new Error(`Unsafe Postgres identifier: ${value}`);
|
|
38434
38810
|
}
|
|
38435
|
-
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
|
+
});
|
|
38436
38831
|
|
|
38437
38832
|
// src/storage/shadow-outbox.ts
|
|
38438
38833
|
class TodosShadowOutbox {
|
|
@@ -38684,6 +39079,7 @@ function createTodosShadowOutbox(options) {
|
|
|
38684
39079
|
var MAX_BACKOFF_MS;
|
|
38685
39080
|
var init_shadow_outbox = __esm(() => {
|
|
38686
39081
|
init_local_sqlite();
|
|
39082
|
+
init_postgres_sync();
|
|
38687
39083
|
init_shadow_outbox_schema();
|
|
38688
39084
|
init_shadow_outbox_schema();
|
|
38689
39085
|
MAX_BACKOFF_MS = 5 * 60000;
|
|
@@ -39188,6 +39584,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
39188
39584
|
getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
|
|
39189
39585
|
list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
|
|
39190
39586
|
update: (id, input) => updateProject2(id, input, store),
|
|
39587
|
+
rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
|
|
39191
39588
|
delete: (id, context) => store.delete("projects", id, context)
|
|
39192
39589
|
},
|
|
39193
39590
|
plans: {
|
|
@@ -39425,9 +39822,22 @@ class PostgresJsonRecordStore {
|
|
|
39425
39822
|
});
|
|
39426
39823
|
}
|
|
39427
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
|
+
}
|
|
39428
39836
|
await this.ensureSchema();
|
|
39429
39837
|
const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
|
|
39430
|
-
|
|
39838
|
+
let result;
|
|
39839
|
+
try {
|
|
39840
|
+
result = await this.options.client.query(`INSERT INTO ${this.tableName} (
|
|
39431
39841
|
service, object_type, object_id, payload, updated_at,
|
|
39432
39842
|
deleted_at, source_machine_id, version
|
|
39433
39843
|
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, $7)
|
|
@@ -39442,14 +39852,23 @@ class PostgresJsonRecordStore {
|
|
|
39442
39852
|
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
39443
39853
|
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
|
|
39444
39854
|
RETURNING object_id`, [
|
|
39445
|
-
|
|
39446
|
-
|
|
39447
|
-
|
|
39448
|
-
|
|
39449
|
-
|
|
39450
|
-
|
|
39451
|
-
|
|
39452
|
-
|
|
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
|
+
}
|
|
39453
39872
|
if (result.rows.length === 0) {
|
|
39454
39873
|
const current = await this.get(type, value.id);
|
|
39455
39874
|
if (current)
|
|
@@ -39457,6 +39876,93 @@ class PostgresJsonRecordStore {
|
|
|
39457
39876
|
}
|
|
39458
39877
|
return value;
|
|
39459
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
|
+
}
|
|
39460
39966
|
async incrementProjectTaskCounter(projectId, _context = {}) {
|
|
39461
39967
|
await this.ensureSchema();
|
|
39462
39968
|
const result = await this.options.client.query(`UPDATE ${this.tableName}
|
|
@@ -39876,12 +40382,16 @@ async function getChangedSince(since, filters, store) {
|
|
|
39876
40382
|
}
|
|
39877
40383
|
async function createProject2(input, store, context) {
|
|
39878
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");
|
|
39879
40389
|
const project = {
|
|
39880
40390
|
id: randomUUID3(),
|
|
39881
40391
|
name: input.name,
|
|
39882
40392
|
path: input.path,
|
|
39883
40393
|
description: input.description ?? null,
|
|
39884
|
-
task_list_id:
|
|
40394
|
+
task_list_id: taskListId,
|
|
39885
40395
|
task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
|
|
39886
40396
|
task_counter: 0,
|
|
39887
40397
|
created_at: timestamp,
|
|
@@ -39892,6 +40402,9 @@ async function createProject2(input, store, context) {
|
|
|
39892
40402
|
return store.upsert("projects", project, context);
|
|
39893
40403
|
}
|
|
39894
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
|
+
}
|
|
39895
40408
|
const project = await requireRecord("projects", id, store);
|
|
39896
40409
|
const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
|
|
39897
40410
|
return store.upsert("projects", updated);
|
|
@@ -40000,10 +40513,13 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
|
|
|
40000
40513
|
}
|
|
40001
40514
|
async function createTaskList2(input, store, context) {
|
|
40002
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");
|
|
40003
40519
|
return store.upsert("task_lists", {
|
|
40004
40520
|
id: randomUUID3(),
|
|
40005
40521
|
project_id: input.project_id ?? context?.projectId ?? null,
|
|
40006
|
-
slug
|
|
40522
|
+
slug,
|
|
40007
40523
|
name: input.name,
|
|
40008
40524
|
description: input.description ?? null,
|
|
40009
40525
|
metadata: input.metadata ?? {},
|
|
@@ -40015,9 +40531,20 @@ async function createTaskList2(input, store, context) {
|
|
|
40015
40531
|
}
|
|
40016
40532
|
async function updateTaskList2(id, input, store) {
|
|
40017
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
|
+
}
|
|
40018
40545
|
return store.upsert("task_lists", {
|
|
40019
40546
|
...list,
|
|
40020
|
-
...
|
|
40547
|
+
...patch,
|
|
40021
40548
|
metadata: input.metadata ?? list.metadata,
|
|
40022
40549
|
updated_at: new Date().toISOString()
|
|
40023
40550
|
});
|
|
@@ -40101,6 +40628,16 @@ async function exportSnapshot(store) {
|
|
|
40101
40628
|
}
|
|
40102
40629
|
async function importSnapshot(snapshot, store, context) {
|
|
40103
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;
|
|
40104
40641
|
const entries = [
|
|
40105
40642
|
...snapshot.tasks.map((row) => ["tasks", row]),
|
|
40106
40643
|
...snapshot.projects.map((row) => ["projects", row]),
|
|
@@ -40171,10 +40708,7 @@ async function generateProjectPrefix(name, store) {
|
|
|
40171
40708
|
return candidate;
|
|
40172
40709
|
}
|
|
40173
40710
|
function slugifyRaw(value) {
|
|
40174
|
-
return value
|
|
40175
|
-
}
|
|
40176
|
-
function slugify2(value) {
|
|
40177
|
-
return slugifyRaw(value) || "todos";
|
|
40711
|
+
return normalizeSlug(value);
|
|
40178
40712
|
}
|
|
40179
40713
|
function normalizePlanSlug2(value) {
|
|
40180
40714
|
const slug = slugifyRaw(value);
|
|
@@ -40228,9 +40762,20 @@ function compareClock(left, right) {
|
|
|
40228
40762
|
function numberValue2(value) {
|
|
40229
40763
|
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
40230
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
|
+
}
|
|
40231
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";
|
|
40232
40776
|
var init_postgres_adapter = __esm(() => {
|
|
40233
40777
|
init_types();
|
|
40778
|
+
init_postgres_sync();
|
|
40234
40779
|
init_redaction();
|
|
40235
40780
|
});
|
|
40236
40781
|
|
|
@@ -40331,6 +40876,7 @@ function isCommentRedactionBackfillComplete(result) {
|
|
|
40331
40876
|
var COMMENT_REDACTION_BACKFILL_CONFIRMATION = "REDACT_STORED_TODOS_COMMENTS";
|
|
40332
40877
|
var init_comment_redaction_backfill = __esm(() => {
|
|
40333
40878
|
init_redaction();
|
|
40879
|
+
init_postgres_sync();
|
|
40334
40880
|
});
|
|
40335
40881
|
|
|
40336
40882
|
// src/server/cloud.ts
|
|
@@ -40344,6 +40890,7 @@ __export(exports_cloud, {
|
|
|
40344
40890
|
getCloudVerifier: () => getCloudVerifier,
|
|
40345
40891
|
getCloudStorageAdapter: () => getCloudStorageAdapter,
|
|
40346
40892
|
getApiKeyStore: () => getApiKeyStore,
|
|
40893
|
+
ensureCloudScopedSlugUniqueIndexes: () => ensureCloudScopedSlugUniqueIndexes,
|
|
40347
40894
|
ensureCloudSchema: () => ensureCloudSchema,
|
|
40348
40895
|
ensureCloudCommentCursorIndex: () => ensureCloudCommentCursorIndex,
|
|
40349
40896
|
closeCloud: () => closeCloud,
|
|
@@ -40428,6 +40975,9 @@ async function ensureCloudSchema() {
|
|
|
40428
40975
|
async function ensureCloudCommentCursorIndex() {
|
|
40429
40976
|
await getClient().query(postgresTodosCommentCursorIndexSql());
|
|
40430
40977
|
}
|
|
40978
|
+
async function ensureCloudScopedSlugUniqueIndexes() {
|
|
40979
|
+
await ensurePostgresScopedSlugUniqueIndexes(getClient());
|
|
40980
|
+
}
|
|
40431
40981
|
async function normalizeCloudPayloads() {
|
|
40432
40982
|
const client = getClient();
|
|
40433
40983
|
const res = await client.query(`UPDATE todos_sync_records
|
|
@@ -40459,6 +41009,7 @@ var init_cloud = __esm(() => {
|
|
|
40459
41009
|
init_auth();
|
|
40460
41010
|
init_cloud_client();
|
|
40461
41011
|
init_postgres_adapter();
|
|
41012
|
+
init_postgres_sync();
|
|
40462
41013
|
init_comment_redaction_backfill();
|
|
40463
41014
|
});
|
|
40464
41015
|
|
|
@@ -40483,6 +41034,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
40483
41034
|
schemas: {
|
|
40484
41035
|
Task: taskSchema,
|
|
40485
41036
|
Project: projectSchema,
|
|
41037
|
+
TaskList: taskListSchema,
|
|
40486
41038
|
TaskComment: taskCommentSchema,
|
|
40487
41039
|
CreateTaskInput: {
|
|
40488
41040
|
type: "object",
|
|
@@ -40511,12 +41063,65 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
40511
41063
|
},
|
|
40512
41064
|
CreateProjectInput: {
|
|
40513
41065
|
type: "object",
|
|
41066
|
+
additionalProperties: false,
|
|
40514
41067
|
required: ["name", "path"],
|
|
40515
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].*" },
|
|
40516
41122
|
name: { type: "string" },
|
|
40517
|
-
path: { type: "string" },
|
|
40518
41123
|
description: { type: "string" },
|
|
40519
|
-
|
|
41124
|
+
metadata: { type: "object", additionalProperties: true }
|
|
40520
41125
|
}
|
|
40521
41126
|
},
|
|
40522
41127
|
CreateTaskCommentInput: {
|
|
@@ -40725,7 +41330,10 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
40725
41330
|
required: true,
|
|
40726
41331
|
content: { "application/json": { schema: { $ref: "#/components/schemas/CreateProjectInput" } } }
|
|
40727
41332
|
},
|
|
40728
|
-
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
|
+
}
|
|
40729
41337
|
}
|
|
40730
41338
|
},
|
|
40731
41339
|
"/v1/projects/{id}": {
|
|
@@ -40734,6 +41342,86 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
40734
41342
|
summary: "Get a project by id",
|
|
40735
41343
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
40736
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" } } } } } } }
|
|
40737
41425
|
}
|
|
40738
41426
|
},
|
|
40739
41427
|
"/v1/stats": {
|
|
@@ -40800,7 +41488,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
40800
41488
|
}
|
|
40801
41489
|
};
|
|
40802
41490
|
}
|
|
40803
|
-
var taskSchema, projectSchema, taskCommentSchema;
|
|
41491
|
+
var taskSchema, projectSchema, taskListSchema, taskCommentSchema;
|
|
40804
41492
|
var init_openapi = __esm(() => {
|
|
40805
41493
|
init_package_version();
|
|
40806
41494
|
taskSchema = {
|
|
@@ -40827,6 +41515,22 @@ var init_openapi = __esm(() => {
|
|
|
40827
41515
|
name: { type: "string" },
|
|
40828
41516
|
path: { type: "string" },
|
|
40829
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 },
|
|
40830
41534
|
created_at: { type: "string" },
|
|
40831
41535
|
updated_at: { type: "string" }
|
|
40832
41536
|
}
|
|
@@ -40860,6 +41564,48 @@ function json2(body, status = 200) {
|
|
|
40860
41564
|
function error(status, message, extra) {
|
|
40861
41565
|
return json2({ error: message, ...extra ?? {} }, status);
|
|
40862
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
|
+
}
|
|
40863
41609
|
async function readJson(req) {
|
|
40864
41610
|
try {
|
|
40865
41611
|
const text = await req.text();
|
|
@@ -41340,14 +42086,31 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
41340
42086
|
}
|
|
41341
42087
|
if (method === "POST") {
|
|
41342
42088
|
const body = await readJson(req);
|
|
41343
|
-
if (!body
|
|
41344
|
-
return error(400, "
|
|
41345
|
-
|
|
41346
|
-
|
|
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));
|
|
41347
42095
|
return json2({ project }, 201);
|
|
41348
42096
|
}
|
|
41349
42097
|
return error(405, `method ${method} not allowed on /v1/projects`);
|
|
41350
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
|
+
}
|
|
41351
42114
|
if (method === "GET") {
|
|
41352
42115
|
const project = await store.projects.get(id);
|
|
41353
42116
|
return project ? json2({ project }) : error(404, "project not found");
|
|
@@ -41356,8 +42119,13 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
41356
42119
|
const body = await readJson(req);
|
|
41357
42120
|
if (!body)
|
|
41358
42121
|
return error(400, "invalid JSON body");
|
|
41359
|
-
const
|
|
41360
|
-
|
|
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 });
|
|
41361
42129
|
}
|
|
41362
42130
|
if (method === "DELETE") {
|
|
41363
42131
|
await store.projects.delete(id, contextFromPrincipal(principal));
|
|
@@ -41444,6 +42212,21 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
41444
42212
|
const body = await readJson(req);
|
|
41445
42213
|
if (!body || typeof body.name !== "string" || !body.name.trim())
|
|
41446
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
|
+
}
|
|
41447
42230
|
const taskList = await store.taskLists.create(body, contextFromPrincipal(principal));
|
|
41448
42231
|
return json2({ task_list: taskList }, 201);
|
|
41449
42232
|
}
|
|
@@ -41451,6 +42234,29 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
41451
42234
|
const taskList = await store.taskLists.get(id);
|
|
41452
42235
|
return taskList ? json2({ task_list: taskList }) : error(404, "task list not found");
|
|
41453
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
|
+
}
|
|
41454
42260
|
if (id && method === "DELETE") {
|
|
41455
42261
|
const deleted = await store.taskLists.delete(id, contextFromPrincipal(principal));
|
|
41456
42262
|
return deleted ? json2({ deleted: true, id }) : error(404, "task list not found");
|
|
@@ -41523,6 +42329,10 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
41523
42329
|
} catch (e) {
|
|
41524
42330
|
if (e instanceof LockError)
|
|
41525
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 });
|
|
41526
42336
|
return error(500, e.message || "internal error");
|
|
41527
42337
|
}
|
|
41528
42338
|
}
|
|
@@ -79668,6 +80478,7 @@ function createHybridTodosStorageAdapter(options) {
|
|
|
79668
80478
|
};
|
|
79669
80479
|
}
|
|
79670
80480
|
var init_hybrid = __esm(() => {
|
|
80481
|
+
init_postgres_sync();
|
|
79671
80482
|
init_local_sqlite();
|
|
79672
80483
|
});
|
|
79673
80484
|
|
|
@@ -79925,6 +80736,19 @@ function createShadowTodosStorageAdapter(options) {
|
|
|
79925
80736
|
mirror.enqueueUpsert("projects", project, context);
|
|
79926
80737
|
return project;
|
|
79927
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
|
+
},
|
|
79928
80752
|
async delete(id, context) {
|
|
79929
80753
|
const deleted = await local.projects.delete(id, context);
|
|
79930
80754
|
if (deleted)
|
|
@@ -80027,6 +80851,7 @@ function isAgent(value) {
|
|
|
80027
80851
|
var SNAPSHOT_TO_OBJECT_TYPE;
|
|
80028
80852
|
var init_shadow = __esm(() => {
|
|
80029
80853
|
init_local_sqlite();
|
|
80854
|
+
init_postgres_sync();
|
|
80030
80855
|
SNAPSHOT_TO_OBJECT_TYPE = {
|
|
80031
80856
|
tasks: "tasks",
|
|
80032
80857
|
projects: "projects",
|
|
@@ -80542,6 +81367,9 @@ __export(exports_storage, {
|
|
|
80542
81367
|
signAwsV4Request: () => signAwsV4Request,
|
|
80543
81368
|
registerShadowExitFlush: () => registerShadowExitFlush,
|
|
80544
81369
|
postgresTodosSyncSchemaSql: () => postgresTodosSyncSchemaSql,
|
|
81370
|
+
postgresTodosScopedSlugUniqueIndexSql: () => postgresTodosScopedSlugUniqueIndexSql,
|
|
81371
|
+
postgresTodosScopedSlugPreflightSql: () => postgresTodosScopedSlugPreflightSql,
|
|
81372
|
+
postgresTodosScopedSlugIndexStatusSql: () => postgresTodosScopedSlugIndexStatusSql,
|
|
80545
81373
|
postgresTodosCommentCursorIndexSql: () => postgresTodosCommentCursorIndexSql,
|
|
80546
81374
|
planRunArtifactsS3Sync: () => planRunArtifactsS3Sync,
|
|
80547
81375
|
parseStorageMode: () => parseStorageMode,
|
|
@@ -80564,6 +81392,7 @@ __export(exports_storage, {
|
|
|
80564
81392
|
getRuntimeShadowOutbox: () => getRuntimeShadowOutbox,
|
|
80565
81393
|
getCanonicalTodosRdsConfig: () => getCanonicalTodosRdsConfig,
|
|
80566
81394
|
exportSqliteTodosStorageSnapshot: () => exportSqliteTodosStorageSnapshot,
|
|
81395
|
+
ensurePostgresScopedSlugUniqueIndexes: () => ensurePostgresScopedSlugUniqueIndexes,
|
|
80567
81396
|
downloadRunArtifactsFromS3: () => downloadRunArtifactsFromS3,
|
|
80568
81397
|
createTodosStorageAdapter: () => createTodosStorageAdapter,
|
|
80569
81398
|
createTodosShadowOutbox: () => createTodosShadowOutbox,
|
|
@@ -80589,6 +81418,8 @@ __export(exports_storage, {
|
|
|
80589
81418
|
STORAGE_TABLES: () => STORAGE_TABLES,
|
|
80590
81419
|
SHADOW_TRIGGER_TABLES: () => SHADOW_TRIGGER_TABLES,
|
|
80591
81420
|
PostgresTodosSyncStore: () => PostgresTodosSyncStore,
|
|
81421
|
+
PostgresScopedSlugMigrationConflictError: () => PostgresScopedSlugMigrationConflictError,
|
|
81422
|
+
PostgresScopedSlugIndexBuildError: () => PostgresScopedSlugIndexBuildError,
|
|
80592
81423
|
DEFAULT_TODOS_POSTGRES_SYNC_TABLE: () => DEFAULT_TODOS_POSTGRES_SYNC_TABLE,
|
|
80593
81424
|
DEFAULT_TODOS_POSTGRES_CURSOR_TABLE: () => DEFAULT_TODOS_POSTGRES_CURSOR_TABLE,
|
|
80594
81425
|
COMMENT_REDACTION_BACKFILL_CONFIRMATION: () => COMMENT_REDACTION_BACKFILL_CONFIRMATION,
|
|
@@ -80606,6 +81437,7 @@ var init_storage2 = __esm(() => {
|
|
|
80606
81437
|
init_hybrid();
|
|
80607
81438
|
init_local_sqlite();
|
|
80608
81439
|
init_sqlite_snapshot();
|
|
81440
|
+
init_postgres_sync();
|
|
80609
81441
|
init_comment_redaction_backfill();
|
|
80610
81442
|
init_postgres_adapter();
|
|
80611
81443
|
init_s3_artifacts();
|