@hasna/todos 0.11.88 → 0.11.90
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/index.js
CHANGED
|
@@ -1748,6 +1748,95 @@ function ensureSchema(db) {
|
|
|
1748
1748
|
)`);
|
|
1749
1749
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_project ON project_machine_paths(project_id)");
|
|
1750
1750
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_machine ON project_machine_paths(machine_id)");
|
|
1751
|
+
ensureTable("canonical_slug_claims", `
|
|
1752
|
+
CREATE TABLE canonical_slug_claims (
|
|
1753
|
+
kind TEXT NOT NULL CHECK(kind IN ('project', 'task_list')),
|
|
1754
|
+
scope_key TEXT NOT NULL,
|
|
1755
|
+
slug TEXT NOT NULL,
|
|
1756
|
+
object_id TEXT NOT NULL,
|
|
1757
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1758
|
+
PRIMARY KEY (kind, scope_key, slug)
|
|
1759
|
+
)`);
|
|
1760
|
+
ensureIndex("CREATE INDEX IF NOT EXISTS idx_canonical_slug_claims_object ON canonical_slug_claims(kind, object_id)");
|
|
1761
|
+
ensureColumn("projects", "task_list_id", "TEXT");
|
|
1762
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_insert
|
|
1763
|
+
BEFORE INSERT ON projects
|
|
1764
|
+
WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
|
|
1765
|
+
BEGIN
|
|
1766
|
+
SELECT CASE WHEN EXISTS (
|
|
1767
|
+
SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
|
|
1768
|
+
) THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
1769
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
1770
|
+
VALUES ('project', 'global', NEW.task_list_id, NEW.id);
|
|
1771
|
+
SELECT CASE WHEN (
|
|
1772
|
+
SELECT object_id FROM canonical_slug_claims
|
|
1773
|
+
WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
|
|
1774
|
+
) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
1775
|
+
END`);
|
|
1776
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_update
|
|
1777
|
+
BEFORE UPDATE OF task_list_id ON projects
|
|
1778
|
+
WHEN NEW.task_list_id IS NOT OLD.task_list_id
|
|
1779
|
+
BEGIN
|
|
1780
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = NEW.id;
|
|
1781
|
+
SELECT CASE WHEN EXISTS (
|
|
1782
|
+
SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
|
|
1783
|
+
) AND NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
|
|
1784
|
+
THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
1785
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
1786
|
+
SELECT 'project', 'global', NEW.task_list_id, NEW.id
|
|
1787
|
+
WHERE NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '';
|
|
1788
|
+
SELECT CASE WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '' AND (
|
|
1789
|
+
SELECT object_id FROM canonical_slug_claims
|
|
1790
|
+
WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
|
|
1791
|
+
) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
1792
|
+
END`);
|
|
1793
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS release_project_canonical_slug_delete
|
|
1794
|
+
AFTER DELETE ON projects
|
|
1795
|
+
BEGIN
|
|
1796
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = OLD.id;
|
|
1797
|
+
END`);
|
|
1798
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_insert
|
|
1799
|
+
BEFORE INSERT ON task_lists
|
|
1800
|
+
WHEN NEW.slug IS NOT NULL AND NEW.slug <> ''
|
|
1801
|
+
BEGIN
|
|
1802
|
+
SELECT CASE WHEN EXISTS (
|
|
1803
|
+
SELECT 1 FROM task_lists
|
|
1804
|
+
WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
|
|
1805
|
+
) THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
1806
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
1807
|
+
VALUES ('task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id);
|
|
1808
|
+
SELECT CASE WHEN (
|
|
1809
|
+
SELECT object_id FROM canonical_slug_claims
|
|
1810
|
+
WHERE kind = 'task_list'
|
|
1811
|
+
AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
|
|
1812
|
+
AND slug = NEW.slug
|
|
1813
|
+
) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
1814
|
+
END`);
|
|
1815
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_update
|
|
1816
|
+
BEFORE UPDATE OF slug, project_id ON task_lists
|
|
1817
|
+
WHEN NEW.slug IS NOT OLD.slug OR NEW.project_id IS NOT OLD.project_id
|
|
1818
|
+
BEGIN
|
|
1819
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = NEW.id;
|
|
1820
|
+
SELECT CASE WHEN EXISTS (
|
|
1821
|
+
SELECT 1 FROM task_lists
|
|
1822
|
+
WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
|
|
1823
|
+
) AND NEW.slug IS NOT NULL AND NEW.slug <> ''
|
|
1824
|
+
THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
1825
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
1826
|
+
SELECT 'task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id
|
|
1827
|
+
WHERE NEW.slug IS NOT NULL AND NEW.slug <> '';
|
|
1828
|
+
SELECT CASE WHEN NEW.slug IS NOT NULL AND NEW.slug <> '' AND (
|
|
1829
|
+
SELECT object_id FROM canonical_slug_claims
|
|
1830
|
+
WHERE kind = 'task_list'
|
|
1831
|
+
AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
|
|
1832
|
+
AND slug = NEW.slug
|
|
1833
|
+
) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
1834
|
+
END`);
|
|
1835
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS release_task_list_canonical_slug_delete
|
|
1836
|
+
AFTER DELETE ON task_lists
|
|
1837
|
+
BEGIN
|
|
1838
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = OLD.id;
|
|
1839
|
+
END`);
|
|
1751
1840
|
ensureTable("storage_tombstones", `
|
|
1752
1841
|
CREATE TABLE storage_tombstones (
|
|
1753
1842
|
id TEXT PRIMARY KEY,
|
|
@@ -3141,7 +3230,7 @@ var init_database = __esm(() => {
|
|
|
3141
3230
|
});
|
|
3142
3231
|
|
|
3143
3232
|
// src/types/index.ts
|
|
3144
|
-
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
3233
|
+
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
3145
3234
|
var init_types = __esm(() => {
|
|
3146
3235
|
TASK_STATUSES = [
|
|
3147
3236
|
"pending",
|
|
@@ -3191,6 +3280,14 @@ var init_types = __esm(() => {
|
|
|
3191
3280
|
this.name = "ProjectNotFoundError";
|
|
3192
3281
|
}
|
|
3193
3282
|
};
|
|
3283
|
+
ResourceConflictError = class ResourceConflictError extends Error {
|
|
3284
|
+
code;
|
|
3285
|
+
constructor(code, message) {
|
|
3286
|
+
super(message);
|
|
3287
|
+
this.code = code;
|
|
3288
|
+
this.name = "ResourceConflictError";
|
|
3289
|
+
}
|
|
3290
|
+
};
|
|
3194
3291
|
PlanNotFoundError = class PlanNotFoundError extends Error {
|
|
3195
3292
|
planId;
|
|
3196
3293
|
static code = "PLAN_NOT_FOUND";
|
|
@@ -3496,9 +3593,94 @@ var init_storage_tombstones = __esm(() => {
|
|
|
3496
3593
|
init_machines();
|
|
3497
3594
|
});
|
|
3498
3595
|
|
|
3596
|
+
// src/lib/slugs.ts
|
|
3597
|
+
function normalizeSlug(value) {
|
|
3598
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
3599
|
+
}
|
|
3600
|
+
function isCanonicalSlug(value) {
|
|
3601
|
+
return typeof value === "string" && value.length > 0 && normalizeSlug(value) === value;
|
|
3602
|
+
}
|
|
3603
|
+
function isValidTaskListProjectScope(value) {
|
|
3604
|
+
return value === undefined || value === null || typeof value === "string" && value.trim().length > 0;
|
|
3605
|
+
}
|
|
3606
|
+
function validateSnapshotRoutingRecords(projects, taskLists) {
|
|
3607
|
+
const errors = [];
|
|
3608
|
+
const projectSlugs = new Map;
|
|
3609
|
+
const taskListSlugs = new Map;
|
|
3610
|
+
for (const project of projects) {
|
|
3611
|
+
if (!isCanonicalSlug(project.task_list_id)) {
|
|
3612
|
+
errors.push(`project ${project.id}: task_list_id must be non-empty canonical kebab-case`);
|
|
3613
|
+
continue;
|
|
3614
|
+
}
|
|
3615
|
+
const existing = projectSlugs.get(project.task_list_id);
|
|
3616
|
+
if (projectSlugs.has(project.task_list_id)) {
|
|
3617
|
+
errors.push(`project ${project.id}: task_list_id duplicates project ${existing}: ${project.task_list_id}`);
|
|
3618
|
+
} else {
|
|
3619
|
+
projectSlugs.set(project.task_list_id, project.id);
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
for (const taskList of taskLists) {
|
|
3623
|
+
if (!isCanonicalSlug(taskList.slug)) {
|
|
3624
|
+
errors.push(`task list ${taskList.id}: slug must be non-empty canonical kebab-case`);
|
|
3625
|
+
continue;
|
|
3626
|
+
}
|
|
3627
|
+
if (!isValidTaskListProjectScope(taskList.project_id)) {
|
|
3628
|
+
errors.push(`task list ${taskList.id}: project_id must be null, missing, or a non-empty string`);
|
|
3629
|
+
continue;
|
|
3630
|
+
}
|
|
3631
|
+
const scope = taskList.project_id ?? null;
|
|
3632
|
+
const scopedSlugs = taskListSlugs.get(scope) ?? new Map;
|
|
3633
|
+
const existing = scopedSlugs.get(taskList.slug);
|
|
3634
|
+
if (scopedSlugs.has(taskList.slug)) {
|
|
3635
|
+
errors.push(`task list ${taskList.id}: slug duplicates task list ${existing} in the same scope: ${taskList.slug}`);
|
|
3636
|
+
} else {
|
|
3637
|
+
scopedSlugs.set(taskList.slug, taskList.id);
|
|
3638
|
+
taskListSlugs.set(scope, scopedSlugs);
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
return errors;
|
|
3642
|
+
}
|
|
3643
|
+
function validateSnapshotRoutingDestinationConflicts(projects, taskLists, existingProjects, existingTaskLists) {
|
|
3644
|
+
const errors = [];
|
|
3645
|
+
for (const project of projects) {
|
|
3646
|
+
const current = existingProjects.find((candidate) => candidate.id === project.id);
|
|
3647
|
+
if (current?.task_list_id === project.task_list_id)
|
|
3648
|
+
continue;
|
|
3649
|
+
const conflict = existingProjects.find((candidate) => candidate.id !== project.id && candidate.task_list_id === project.task_list_id);
|
|
3650
|
+
if (conflict) {
|
|
3651
|
+
errors.push(`project ${project.id}: task_list_id conflicts with existing project ${conflict.id}: ${String(project.task_list_id)}`);
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
for (const taskList of taskLists) {
|
|
3655
|
+
const projectId = taskList.project_id ?? null;
|
|
3656
|
+
const current = existingTaskLists.find((candidate) => candidate.id === taskList.id);
|
|
3657
|
+
if ((current?.project_id ?? null) === projectId && current?.slug === taskList.slug)
|
|
3658
|
+
continue;
|
|
3659
|
+
const conflict = existingTaskLists.find((candidate) => candidate.id !== taskList.id && (candidate.project_id ?? null) === projectId && candidate.slug === taskList.slug);
|
|
3660
|
+
if (conflict) {
|
|
3661
|
+
errors.push(`task list ${taskList.id}: slug conflicts with existing task list ${conflict.id} in the same scope: ${String(taskList.slug)}`);
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
return errors;
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
// src/db/slug-claims.ts
|
|
3668
|
+
function taskListSlugScopeKey(projectId) {
|
|
3669
|
+
return projectId ? `project:${projectId}` : "standalone:";
|
|
3670
|
+
}
|
|
3671
|
+
function claimCanonicalSlug(kind, scopeKey, slug, objectId, db) {
|
|
3672
|
+
db.run(`INSERT OR IGNORE INTO canonical_slug_claims (kind, scope_key, slug, object_id)
|
|
3673
|
+
VALUES (?, ?, ?, ?)`, [kind, scopeKey, slug, objectId]);
|
|
3674
|
+
const claim = db.query("SELECT object_id FROM canonical_slug_claims WHERE kind = ? AND scope_key = ? AND slug = ?").get(kind, scopeKey, slug);
|
|
3675
|
+
return claim?.object_id === objectId;
|
|
3676
|
+
}
|
|
3677
|
+
function releaseCanonicalSlugClaims(kind, objectId, db) {
|
|
3678
|
+
db.run("DELETE FROM canonical_slug_claims WHERE kind = ? AND object_id = ?", [kind, objectId]);
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3499
3681
|
// src/db/projects.ts
|
|
3500
3682
|
function slugify(name) {
|
|
3501
|
-
return name
|
|
3683
|
+
return normalizeSlug(name);
|
|
3502
3684
|
}
|
|
3503
3685
|
function generatePrefix(name, db) {
|
|
3504
3686
|
const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
|
|
@@ -3522,14 +3704,23 @@ function generatePrefix(name, db) {
|
|
|
3522
3704
|
}
|
|
3523
3705
|
function createProject(input, db) {
|
|
3524
3706
|
const d = db || getDatabase();
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3707
|
+
return d.transaction(() => {
|
|
3708
|
+
const id = uuid();
|
|
3709
|
+
const timestamp = now();
|
|
3710
|
+
const derivedSlug = slugify(input.name);
|
|
3711
|
+
const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
|
|
3712
|
+
if (!derivedSlug || !taskListId)
|
|
3713
|
+
throw new Error("Project name and task-list slug must be non-empty");
|
|
3714
|
+
const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
|
|
3715
|
+
if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
|
|
3716
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
|
|
3717
|
+
}
|
|
3718
|
+
const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
|
|
3719
|
+
const machineId = currentStorageMachineId(d);
|
|
3720
|
+
d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
|
|
3721
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
|
|
3722
|
+
return getProject(id, d);
|
|
3723
|
+
})();
|
|
3533
3724
|
}
|
|
3534
3725
|
function getProject(id, db) {
|
|
3535
3726
|
const d = db || getDatabase();
|
|
@@ -3557,6 +3748,9 @@ function updateProject(id, input, db) {
|
|
|
3557
3748
|
const project = getProject(id, d);
|
|
3558
3749
|
if (!project)
|
|
3559
3750
|
throw new ProjectNotFoundError(id);
|
|
3751
|
+
if ("task_list_id" in input) {
|
|
3752
|
+
throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
|
|
3753
|
+
}
|
|
3560
3754
|
const sets = ["updated_at = ?"];
|
|
3561
3755
|
const params = [now()];
|
|
3562
3756
|
if (input.name !== undefined) {
|
|
@@ -3567,10 +3761,6 @@ function updateProject(id, input, db) {
|
|
|
3567
3761
|
sets.push("description = ?");
|
|
3568
3762
|
params.push(input.description);
|
|
3569
3763
|
}
|
|
3570
|
-
if (input.task_list_id !== undefined) {
|
|
3571
|
-
sets.push("task_list_id = ?");
|
|
3572
|
-
params.push(input.task_list_id);
|
|
3573
|
-
}
|
|
3574
3764
|
if (input.path !== undefined) {
|
|
3575
3765
|
sets.push("path = ?");
|
|
3576
3766
|
params.push(input.path);
|
|
@@ -3581,29 +3771,41 @@ function updateProject(id, input, db) {
|
|
|
3581
3771
|
}
|
|
3582
3772
|
function renameProject(id, input, db) {
|
|
3583
3773
|
const d = db || getDatabase();
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3774
|
+
return d.transaction(() => {
|
|
3775
|
+
const project = getProject(id, d);
|
|
3776
|
+
if (!project)
|
|
3777
|
+
throw new ProjectNotFoundError(id);
|
|
3778
|
+
let taskListsUpdated = 0;
|
|
3779
|
+
const ts = now();
|
|
3780
|
+
if (input.new_slug !== undefined) {
|
|
3781
|
+
const normalised = normalizeSlug(input.new_slug);
|
|
3782
|
+
if (!normalised)
|
|
3783
|
+
throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
|
|
3784
|
+
const oldSlug = project.task_list_id;
|
|
3785
|
+
if (normalised !== oldSlug) {
|
|
3786
|
+
const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
|
|
3787
|
+
if (conflict) {
|
|
3788
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
|
|
3789
|
+
}
|
|
3790
|
+
const taskListConflict = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND slug != COALESCE(?, '') LIMIT 1").get(id, normalised, oldSlug);
|
|
3791
|
+
if (taskListConflict) {
|
|
3792
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalised}" is already used in project "${project.name}"`);
|
|
3793
|
+
}
|
|
3794
|
+
releaseCanonicalSlugClaims("project", id, d);
|
|
3795
|
+
if (!claimCanonicalSlug("project", "global", normalised, id, d)) {
|
|
3796
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
|
|
3797
|
+
}
|
|
3798
|
+
d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
|
|
3799
|
+
}
|
|
3800
|
+
if (oldSlug && (normalised !== oldSlug || input.name !== undefined && input.name !== project.name)) {
|
|
3801
|
+
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;
|
|
3802
|
+
}
|
|
3601
3803
|
}
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3804
|
+
if (input.name !== undefined && input.name !== project.name) {
|
|
3805
|
+
d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
|
|
3806
|
+
}
|
|
3807
|
+
return { project: getProject(id, d), task_lists_updated: taskListsUpdated };
|
|
3808
|
+
})();
|
|
3607
3809
|
}
|
|
3608
3810
|
function deleteProject(id, db) {
|
|
3609
3811
|
const d = db || getDatabase();
|
|
@@ -3615,8 +3817,10 @@ function deleteProject(id, db) {
|
|
|
3615
3817
|
object_id: id,
|
|
3616
3818
|
payload: project
|
|
3617
3819
|
}, d);
|
|
3618
|
-
|
|
3619
|
-
|
|
3820
|
+
return d.transaction(() => {
|
|
3821
|
+
releaseCanonicalSlugClaims("project", id, d);
|
|
3822
|
+
return d.run("DELETE FROM projects WHERE id = ?", [id]).changes > 0;
|
|
3823
|
+
})();
|
|
3620
3824
|
}
|
|
3621
3825
|
function rowToSource(row) {
|
|
3622
3826
|
return {
|
|
@@ -5175,19 +5379,22 @@ function rowToTaskList(row) {
|
|
|
5175
5379
|
}
|
|
5176
5380
|
function createTaskList(input, db) {
|
|
5177
5381
|
const d = db || getDatabase();
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5382
|
+
return d.transaction(() => {
|
|
5383
|
+
const id = uuid();
|
|
5384
|
+
const timestamp = now();
|
|
5385
|
+
const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
|
|
5386
|
+
if (!slug)
|
|
5387
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
5388
|
+
const machineId = currentStorageMachineId(d);
|
|
5389
|
+
const scopeKey = taskListSlugScopeKey(input.project_id);
|
|
5390
|
+
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);
|
|
5391
|
+
if (existing || !claimCanonicalSlug("task_list", scopeKey, slug, id, d)) {
|
|
5392
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
5393
|
+
}
|
|
5394
|
+
d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
|
|
5395
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
|
|
5396
|
+
return getTaskList(id, d);
|
|
5397
|
+
})();
|
|
5191
5398
|
}
|
|
5192
5399
|
function getTaskList(id, db) {
|
|
5193
5400
|
const d = db || getDatabase();
|
|
@@ -5213,26 +5420,45 @@ function listTaskLists(projectId, db) {
|
|
|
5213
5420
|
}
|
|
5214
5421
|
function updateTaskList(id, input, db) {
|
|
5215
5422
|
const d = db || getDatabase();
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5423
|
+
return d.transaction(() => {
|
|
5424
|
+
const existing = getTaskList(id, d);
|
|
5425
|
+
if (!existing)
|
|
5426
|
+
throw new TaskListNotFoundError(id);
|
|
5427
|
+
const sets = ["updated_at = ?"];
|
|
5428
|
+
const params = [now()];
|
|
5429
|
+
if (input.slug !== undefined) {
|
|
5430
|
+
const slug = slugify(input.slug);
|
|
5431
|
+
if (!slug)
|
|
5432
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
5433
|
+
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);
|
|
5434
|
+
if (duplicate) {
|
|
5435
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
5436
|
+
}
|
|
5437
|
+
if (slug !== existing.slug) {
|
|
5438
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
5439
|
+
if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(existing.project_id), slug, id, d)) {
|
|
5440
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
5441
|
+
}
|
|
5442
|
+
}
|
|
5443
|
+
sets.push("slug = ?");
|
|
5444
|
+
params.push(slug);
|
|
5445
|
+
}
|
|
5446
|
+
if (input.name !== undefined) {
|
|
5447
|
+
sets.push("name = ?");
|
|
5448
|
+
params.push(input.name);
|
|
5449
|
+
}
|
|
5450
|
+
if (input.description !== undefined) {
|
|
5451
|
+
sets.push("description = ?");
|
|
5452
|
+
params.push(input.description);
|
|
5453
|
+
}
|
|
5454
|
+
if (input.metadata !== undefined) {
|
|
5455
|
+
sets.push("metadata = ?");
|
|
5456
|
+
params.push(JSON.stringify(input.metadata));
|
|
5457
|
+
}
|
|
5458
|
+
params.push(id);
|
|
5459
|
+
d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
|
|
5460
|
+
return getTaskList(id, d);
|
|
5461
|
+
})();
|
|
5236
5462
|
}
|
|
5237
5463
|
function deleteTaskList(id, db) {
|
|
5238
5464
|
const d = db || getDatabase();
|
|
@@ -5244,7 +5470,10 @@ function deleteTaskList(id, db) {
|
|
|
5244
5470
|
object_id: id,
|
|
5245
5471
|
payload: list
|
|
5246
5472
|
}, d);
|
|
5247
|
-
return d.
|
|
5473
|
+
return d.transaction(() => {
|
|
5474
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
5475
|
+
return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
5476
|
+
})();
|
|
5248
5477
|
}
|
|
5249
5478
|
function ensureTaskList(name, slug, projectId, db) {
|
|
5250
5479
|
const d = db || getDatabase();
|
|
@@ -22982,6 +23211,14 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
22982
23211
|
skipped: 0,
|
|
22983
23212
|
errors: []
|
|
22984
23213
|
};
|
|
23214
|
+
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
23215
|
+
if (result.errors.length === 0) {
|
|
23216
|
+
const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
|
|
23217
|
+
const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
|
|
23218
|
+
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
23219
|
+
}
|
|
23220
|
+
if (result.errors.length > 0)
|
|
23221
|
+
return result;
|
|
22985
23222
|
const applyRows = (objectType, table, columns, rows, updateClockColumn, afterUpsert) => {
|
|
22986
23223
|
for (const row of rows) {
|
|
22987
23224
|
try {
|
|
@@ -23189,6 +23426,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
23189
23426
|
getByPath: (path) => getProjectByPath(path, database()),
|
|
23190
23427
|
list: () => listProjects(database()),
|
|
23191
23428
|
update: (id, input) => updateProject(id, input, database()),
|
|
23429
|
+
rename: (id, input) => renameProject(id, input, database()),
|
|
23192
23430
|
delete: (id) => deleteProject(id, database())
|
|
23193
23431
|
},
|
|
23194
23432
|
plans: {
|
|
@@ -23286,6 +23524,110 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
23286
23524
|
)`
|
|
23287
23525
|
];
|
|
23288
23526
|
}
|
|
23527
|
+
|
|
23528
|
+
class PostgresScopedSlugMigrationConflictError extends Error {
|
|
23529
|
+
conflicts;
|
|
23530
|
+
constructor(conflicts) {
|
|
23531
|
+
const preview = conflicts.slice(0, 5).map((conflict) => `${conflict.object_type}:${conflict.scope || "global"}:${conflict.slug} [${conflict.object_ids.join(", ")}]`).join("; ");
|
|
23532
|
+
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.");
|
|
23533
|
+
this.conflicts = conflicts;
|
|
23534
|
+
this.name = "PostgresScopedSlugMigrationConflictError";
|
|
23535
|
+
}
|
|
23536
|
+
}
|
|
23537
|
+
|
|
23538
|
+
class PostgresScopedSlugIndexBuildError extends Error {
|
|
23539
|
+
index_name;
|
|
23540
|
+
constructor(index_name, cause) {
|
|
23541
|
+
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 });
|
|
23542
|
+
this.index_name = index_name;
|
|
23543
|
+
this.name = "PostgresScopedSlugIndexBuildError";
|
|
23544
|
+
}
|
|
23545
|
+
}
|
|
23546
|
+
function postgresTodosScopedSlugPreflightSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
23547
|
+
assertSafeIdentifier(tableName);
|
|
23548
|
+
return `/* todos:scoped-slug-duplicate-audit */ WITH candidates AS (
|
|
23549
|
+
SELECT service, object_type, COALESCE(payload->>'project_id', '') AS scope,
|
|
23550
|
+
jsonb_typeof(payload->'project_id') AS scope_type,
|
|
23551
|
+
payload->>'slug' AS slug, jsonb_typeof(payload->'slug') AS slug_type, object_id
|
|
23552
|
+
FROM ${tableName}
|
|
23553
|
+
WHERE object_type = 'task_lists' AND deleted_at IS NULL
|
|
23554
|
+
UNION ALL
|
|
23555
|
+
SELECT service, object_type, '' AS scope, NULL::text AS scope_type, payload->>'task_list_id' AS slug,
|
|
23556
|
+
jsonb_typeof(payload->'task_list_id') AS slug_type, object_id
|
|
23557
|
+
FROM ${tableName}
|
|
23558
|
+
WHERE object_type = 'projects' AND deleted_at IS NULL
|
|
23559
|
+
), annotated AS (
|
|
23560
|
+
SELECT *, trim(both '-' from regexp_replace(lower(COALESCE(slug, '')), '[^a-z0-9]+', '-', 'g')) AS normalized_slug
|
|
23561
|
+
FROM candidates
|
|
23562
|
+
), invalid AS (
|
|
23563
|
+
SELECT service, object_type, scope, COALESCE(slug, '<null>') AS slug,
|
|
23564
|
+
ARRAY[object_id] AS object_ids, 1::integer AS duplicate_count, 'invalid'::text AS issue
|
|
23565
|
+
FROM annotated
|
|
23566
|
+
WHERE slug_type IS DISTINCT FROM 'string'
|
|
23567
|
+
OR slug IS NULL OR slug = '' OR normalized_slug = '' OR slug IS DISTINCT FROM normalized_slug
|
|
23568
|
+
OR (object_type = 'task_lists' AND (
|
|
23569
|
+
(scope_type IS NOT NULL AND scope_type NOT IN ('string', 'null'))
|
|
23570
|
+
OR (scope_type = 'string' AND btrim(scope) = '')
|
|
23571
|
+
))
|
|
23572
|
+
), duplicates AS (
|
|
23573
|
+
SELECT service, object_type, scope, slug,
|
|
23574
|
+
array_agg(object_id ORDER BY object_id) AS object_ids,
|
|
23575
|
+
count(*)::integer AS duplicate_count, 'duplicate'::text AS issue
|
|
23576
|
+
FROM annotated
|
|
23577
|
+
WHERE slug = normalized_slug AND slug <> ''
|
|
23578
|
+
GROUP BY service, object_type, scope, slug
|
|
23579
|
+
HAVING count(*) > 1
|
|
23580
|
+
) SELECT * FROM invalid
|
|
23581
|
+
UNION ALL SELECT * FROM duplicates
|
|
23582
|
+
ORDER BY service, object_type, scope, slug`;
|
|
23583
|
+
}
|
|
23584
|
+
function postgresTodosScopedSlugUniqueIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
23585
|
+
assertSafeIdentifier(tableName);
|
|
23586
|
+
return [
|
|
23587
|
+
`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_task_list_scope_slug_uidx
|
|
23588
|
+
ON ${tableName} (service, COALESCE(payload->>'project_id', ''), (payload->>'slug'))
|
|
23589
|
+
WHERE object_type = 'task_lists' AND deleted_at IS NULL AND COALESCE(payload->>'slug', '') <> ''`,
|
|
23590
|
+
`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_project_task_list_slug_uidx
|
|
23591
|
+
ON ${tableName} (service, (payload->>'task_list_id'))
|
|
23592
|
+
WHERE object_type = 'projects' AND deleted_at IS NULL AND COALESCE(payload->>'task_list_id', '') <> ''`
|
|
23593
|
+
];
|
|
23594
|
+
}
|
|
23595
|
+
function postgresTodosScopedSlugIndexStatusSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
23596
|
+
assertSafeIdentifier(tableName);
|
|
23597
|
+
return `/* todos:scoped-slug-index-status */ SELECT index_class.relname AS index_name,
|
|
23598
|
+
index_meta.indisvalid AS is_valid, index_meta.indisready AS is_ready
|
|
23599
|
+
FROM pg_index index_meta
|
|
23600
|
+
JOIN pg_class index_class ON index_class.oid = index_meta.indexrelid
|
|
23601
|
+
WHERE index_meta.indrelid = to_regclass('${tableName}')
|
|
23602
|
+
AND index_class.relname IN (
|
|
23603
|
+
'${tableName}_task_list_scope_slug_uidx',
|
|
23604
|
+
'${tableName}_project_task_list_slug_uidx'
|
|
23605
|
+
)`;
|
|
23606
|
+
}
|
|
23607
|
+
async function ensurePostgresScopedSlugUniqueIndexes(client, tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
23608
|
+
const audit = await client.query(postgresTodosScopedSlugPreflightSql(tableName));
|
|
23609
|
+
if (audit.rows.length > 0)
|
|
23610
|
+
throw new PostgresScopedSlugMigrationConflictError(audit.rows);
|
|
23611
|
+
for (const sql of postgresTodosScopedSlugUniqueIndexSql(tableName)) {
|
|
23612
|
+
try {
|
|
23613
|
+
await client.query(sql);
|
|
23614
|
+
} catch (error) {
|
|
23615
|
+
const indexName = sql.match(/INDEX CONCURRENTLY IF NOT EXISTS ([a-zA-Z0-9_]+)/)?.[1] ?? "unknown_index";
|
|
23616
|
+
throw new PostgresScopedSlugIndexBuildError(indexName, error);
|
|
23617
|
+
}
|
|
23618
|
+
}
|
|
23619
|
+
const expected = new Set([
|
|
23620
|
+
`${tableName}_task_list_scope_slug_uidx`,
|
|
23621
|
+
`${tableName}_project_task_list_slug_uidx`
|
|
23622
|
+
]);
|
|
23623
|
+
const status = await client.query(postgresTodosScopedSlugIndexStatusSql(tableName));
|
|
23624
|
+
for (const indexName of expected) {
|
|
23625
|
+
const row = status.rows.find((candidate) => candidate.index_name === indexName);
|
|
23626
|
+
if (!row?.is_valid || !row.is_ready) {
|
|
23627
|
+
throw new PostgresScopedSlugIndexBuildError(indexName, new Error("index is missing, invalid, or not ready"));
|
|
23628
|
+
}
|
|
23629
|
+
}
|
|
23630
|
+
}
|
|
23289
23631
|
function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
23290
23632
|
assertSafeIdentifier(tableName);
|
|
23291
23633
|
return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
|
|
@@ -23314,9 +23656,31 @@ class PostgresTodosSyncStore {
|
|
|
23314
23656
|
}
|
|
23315
23657
|
}
|
|
23316
23658
|
async pushSnapshot(snapshot, context = {}) {
|
|
23659
|
+
const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
|
|
23660
|
+
if (routingErrors.length > 0) {
|
|
23661
|
+
throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
|
|
23662
|
+
}
|
|
23663
|
+
const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
|
|
23664
|
+
FROM ${this.tableName}
|
|
23665
|
+
WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
|
|
23666
|
+
const existingProjects = [];
|
|
23667
|
+
const existingTaskLists = [];
|
|
23668
|
+
for (const row of existing.rows) {
|
|
23669
|
+
const payload = payloadRecord(row.payload);
|
|
23670
|
+
if (row.object_type === "projects")
|
|
23671
|
+
existingProjects.push(payload);
|
|
23672
|
+
if (row.object_type === "task_lists")
|
|
23673
|
+
existingTaskLists.push(payload);
|
|
23674
|
+
}
|
|
23675
|
+
const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
|
|
23676
|
+
if (destinationErrors.length > 0) {
|
|
23677
|
+
throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
|
|
23678
|
+
}
|
|
23317
23679
|
const result = { records: 0, objectTypes: {} };
|
|
23318
23680
|
const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
|
|
23319
23681
|
for (const entry of snapshotEntries(snapshot)) {
|
|
23682
|
+
if (entry.deletedAt === null)
|
|
23683
|
+
assertCanonicalScopedSlugEntry(entry);
|
|
23320
23684
|
await this.client.query(`INSERT INTO ${this.tableName} (
|
|
23321
23685
|
service, object_type, object_id, payload, updated_at,
|
|
23322
23686
|
deleted_at, source_machine_id, version
|
|
@@ -23395,6 +23759,22 @@ function snapshotEntries(snapshot) {
|
|
|
23395
23759
|
}))
|
|
23396
23760
|
];
|
|
23397
23761
|
}
|
|
23762
|
+
function assertCanonicalScopedSlugEntry(entry) {
|
|
23763
|
+
if (!entry.payload || typeof entry.payload !== "object" || Array.isArray(entry.payload))
|
|
23764
|
+
return;
|
|
23765
|
+
const payload = entry.payload;
|
|
23766
|
+
if (entry.type === "projects" && !isCanonicalSlug(payload["task_list_id"])) {
|
|
23767
|
+
throw new Error("Invalid project task-list slug \u2014 sync requires non-empty canonical kebab-case");
|
|
23768
|
+
}
|
|
23769
|
+
if (entry.type === "task_lists") {
|
|
23770
|
+
if (!isCanonicalSlug(payload["slug"])) {
|
|
23771
|
+
throw new Error("Invalid task-list slug \u2014 sync requires non-empty canonical kebab-case");
|
|
23772
|
+
}
|
|
23773
|
+
if (!isValidTaskListProjectScope(payload["project_id"])) {
|
|
23774
|
+
throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
|
|
23775
|
+
}
|
|
23776
|
+
}
|
|
23777
|
+
}
|
|
23398
23778
|
function entry(type, payload, fallbackUpdatedAt) {
|
|
23399
23779
|
const id = payload["id"];
|
|
23400
23780
|
if (typeof id !== "string" || !id)
|
|
@@ -23582,6 +23962,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
23582
23962
|
getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
|
|
23583
23963
|
list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
|
|
23584
23964
|
update: (id, input) => updateProject2(id, input, store),
|
|
23965
|
+
rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
|
|
23585
23966
|
delete: (id, context) => store.delete("projects", id, context)
|
|
23586
23967
|
},
|
|
23587
23968
|
plans: {
|
|
@@ -23819,9 +24200,22 @@ class PostgresJsonRecordStore {
|
|
|
23819
24200
|
});
|
|
23820
24201
|
}
|
|
23821
24202
|
async upsert(type, value, context = {}) {
|
|
24203
|
+
if (type === "projects" && !isCanonicalSlug(value.task_list_id)) {
|
|
24204
|
+
throw new Error("Invalid project task-list slug \u2014 imports require non-empty canonical kebab-case");
|
|
24205
|
+
}
|
|
24206
|
+
if (type === "task_lists") {
|
|
24207
|
+
if (!isCanonicalSlug(value.slug)) {
|
|
24208
|
+
throw new Error("Invalid task-list slug \u2014 imports require non-empty canonical kebab-case");
|
|
24209
|
+
}
|
|
24210
|
+
if (!isValidTaskListProjectScope(value.project_id)) {
|
|
24211
|
+
throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
|
|
24212
|
+
}
|
|
24213
|
+
}
|
|
23822
24214
|
await this.ensureSchema();
|
|
23823
24215
|
const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
|
|
23824
|
-
|
|
24216
|
+
let result;
|
|
24217
|
+
try {
|
|
24218
|
+
result = await this.options.client.query(`INSERT INTO ${this.tableName} (
|
|
23825
24219
|
service, object_type, object_id, payload, updated_at,
|
|
23826
24220
|
deleted_at, source_machine_id, version
|
|
23827
24221
|
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, $7)
|
|
@@ -23836,14 +24230,23 @@ class PostgresJsonRecordStore {
|
|
|
23836
24230
|
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
23837
24231
|
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
|
|
23838
24232
|
RETURNING object_id`, [
|
|
23839
|
-
|
|
23840
|
-
|
|
23841
|
-
|
|
23842
|
-
|
|
23843
|
-
|
|
23844
|
-
|
|
23845
|
-
|
|
23846
|
-
|
|
24233
|
+
this.service,
|
|
24234
|
+
type,
|
|
24235
|
+
value.id,
|
|
24236
|
+
jsonbParam(value),
|
|
24237
|
+
updatedAt,
|
|
24238
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
24239
|
+
numberValue3(value.version)
|
|
24240
|
+
]);
|
|
24241
|
+
} catch (error) {
|
|
24242
|
+
if (type === "task_lists" && isPostgresUniqueViolation(error)) {
|
|
24243
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${String(value.slug ?? "")}" already exists in this scope`);
|
|
24244
|
+
}
|
|
24245
|
+
if (type === "projects" && isPostgresUniqueViolation(error)) {
|
|
24246
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${String(value.task_list_id ?? "")}" already exists`);
|
|
24247
|
+
}
|
|
24248
|
+
throw error;
|
|
24249
|
+
}
|
|
23847
24250
|
if (result.rows.length === 0) {
|
|
23848
24251
|
const current = await this.get(type, value.id);
|
|
23849
24252
|
if (current)
|
|
@@ -23851,6 +24254,93 @@ class PostgresJsonRecordStore {
|
|
|
23851
24254
|
}
|
|
23852
24255
|
return value;
|
|
23853
24256
|
}
|
|
24257
|
+
async renameProject(id, newSlug, name, context = {}) {
|
|
24258
|
+
await this.ensureSchema();
|
|
24259
|
+
const normalizedSlug = slugifyRaw(newSlug);
|
|
24260
|
+
if (!normalizedSlug)
|
|
24261
|
+
throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
|
|
24262
|
+
const timestamp2 = new Date().toISOString();
|
|
24263
|
+
try {
|
|
24264
|
+
const result = await this.options.client.query(`/* todos:rename-project-atomic */ WITH target AS (
|
|
24265
|
+
SELECT payload, payload->>'task_list_id' AS old_slug
|
|
24266
|
+
FROM ${this.tableName}
|
|
24267
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL
|
|
24268
|
+
FOR UPDATE
|
|
24269
|
+
), project_conflict AS (
|
|
24270
|
+
SELECT 1 FROM ${this.tableName}
|
|
24271
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
24272
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3 LIMIT 1
|
|
24273
|
+
), task_list_conflict AS (
|
|
24274
|
+
SELECT 1 FROM ${this.tableName} r, target
|
|
24275
|
+
WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
|
|
24276
|
+
AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = $3
|
|
24277
|
+
AND r.payload->>'slug' IS DISTINCT FROM target.old_slug LIMIT 1
|
|
24278
|
+
), updated_lists AS (
|
|
24279
|
+
UPDATE ${this.tableName} r SET
|
|
24280
|
+
payload = r.payload || jsonb_build_object('slug', $3::text, 'updated_at', $5::text)
|
|
24281
|
+
|| CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
|
|
24282
|
+
updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
|
|
24283
|
+
source_machine_id = COALESCE($6, r.source_machine_id)
|
|
24284
|
+
FROM target
|
|
24285
|
+
WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
|
|
24286
|
+
AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = target.old_slug
|
|
24287
|
+
AND NOT EXISTS (SELECT 1 FROM project_conflict)
|
|
24288
|
+
AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
|
|
24289
|
+
AND (target.old_slug IS DISTINCT FROM $3
|
|
24290
|
+
OR ($4::text IS NOT NULL AND r.payload->>'name' IS DISTINCT FROM $4))
|
|
24291
|
+
RETURNING 1
|
|
24292
|
+
), updated_project AS (
|
|
24293
|
+
UPDATE ${this.tableName} r SET
|
|
24294
|
+
payload = r.payload || jsonb_build_object('task_list_id', $3::text, 'updated_at', $5::text)
|
|
24295
|
+
|| CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
|
|
24296
|
+
updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
|
|
24297
|
+
source_machine_id = COALESCE($6, r.source_machine_id)
|
|
24298
|
+
FROM target
|
|
24299
|
+
WHERE r.service = $1 AND r.object_type = 'projects' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
24300
|
+
AND NOT EXISTS (SELECT 1 FROM project_conflict)
|
|
24301
|
+
AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
|
|
24302
|
+
AND (target.old_slug IS DISTINCT FROM $3
|
|
24303
|
+
OR ($4::text IS NOT NULL AND target.payload->>'name' IS DISTINCT FROM $4))
|
|
24304
|
+
RETURNING r.payload
|
|
24305
|
+
) SELECT
|
|
24306
|
+
EXISTS (SELECT 1 FROM target) AS found,
|
|
24307
|
+
EXISTS (SELECT 1 FROM project_conflict) AS project_conflict,
|
|
24308
|
+
EXISTS (SELECT 1 FROM task_list_conflict) AS task_list_conflict,
|
|
24309
|
+
COALESCE((SELECT payload FROM updated_project), (SELECT payload FROM target)) AS project,
|
|
24310
|
+
(SELECT count(*) FROM updated_lists) AS task_lists_updated`, [this.service, id, normalizedSlug, name ?? null, timestamp2, this.machineId(context)]);
|
|
24311
|
+
const row = result.rows[0];
|
|
24312
|
+
if (!row?.found)
|
|
24313
|
+
throw new ProjectNotFoundError(id);
|
|
24314
|
+
if (row.project_conflict) {
|
|
24315
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
|
|
24316
|
+
}
|
|
24317
|
+
if (row.task_list_conflict) {
|
|
24318
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
|
|
24319
|
+
}
|
|
24320
|
+
return {
|
|
24321
|
+
project: payloadRecord2(row.project),
|
|
24322
|
+
task_lists_updated: Number(row.task_lists_updated)
|
|
24323
|
+
};
|
|
24324
|
+
} catch (error) {
|
|
24325
|
+
if (isPostgresUniqueViolation(error)) {
|
|
24326
|
+
const constraintName = postgresConstraintName(error);
|
|
24327
|
+
let projectConflict = constraintName.includes("project_task_list_slug_uidx");
|
|
24328
|
+
if (!constraintName) {
|
|
24329
|
+
const conflict = await this.options.client.query(`/* todos:classify-project-rename-conflict */ SELECT EXISTS (
|
|
24330
|
+
SELECT 1 FROM ${this.tableName}
|
|
24331
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
24332
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3
|
|
24333
|
+
) AS project_conflict`, [this.service, id, normalizedSlug]);
|
|
24334
|
+
projectConflict = Boolean(conflict.rows[0]?.project_conflict);
|
|
24335
|
+
}
|
|
24336
|
+
if (projectConflict) {
|
|
24337
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
|
|
24338
|
+
}
|
|
24339
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
|
|
24340
|
+
}
|
|
24341
|
+
throw error;
|
|
24342
|
+
}
|
|
24343
|
+
}
|
|
23854
24344
|
async incrementProjectTaskCounter(projectId, _context = {}) {
|
|
23855
24345
|
await this.ensureSchema();
|
|
23856
24346
|
const result = await this.options.client.query(`UPDATE ${this.tableName}
|
|
@@ -24272,12 +24762,16 @@ async function getChangedSince(since, filters, store) {
|
|
|
24272
24762
|
}
|
|
24273
24763
|
async function createProject2(input, store, context) {
|
|
24274
24764
|
const timestamp2 = new Date().toISOString();
|
|
24765
|
+
const derivedSlug = slugifyRaw(input.name);
|
|
24766
|
+
const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
|
|
24767
|
+
if (!derivedSlug || !taskListId)
|
|
24768
|
+
throw new Error("Project name and task-list slug must be non-empty");
|
|
24275
24769
|
const project = {
|
|
24276
24770
|
id: randomUUID3(),
|
|
24277
24771
|
name: input.name,
|
|
24278
24772
|
path: input.path,
|
|
24279
24773
|
description: input.description ?? null,
|
|
24280
|
-
task_list_id:
|
|
24774
|
+
task_list_id: taskListId,
|
|
24281
24775
|
task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
|
|
24282
24776
|
task_counter: 0,
|
|
24283
24777
|
created_at: timestamp2,
|
|
@@ -24288,6 +24782,9 @@ async function createProject2(input, store, context) {
|
|
|
24288
24782
|
return store.upsert("projects", project, context);
|
|
24289
24783
|
}
|
|
24290
24784
|
async function updateProject2(id, input, store) {
|
|
24785
|
+
if ("task_list_id" in input) {
|
|
24786
|
+
throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
|
|
24787
|
+
}
|
|
24291
24788
|
const project = await requireRecord("projects", id, store);
|
|
24292
24789
|
const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
|
|
24293
24790
|
return store.upsert("projects", updated);
|
|
@@ -24396,10 +24893,13 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
|
|
|
24396
24893
|
}
|
|
24397
24894
|
async function createTaskList2(input, store, context) {
|
|
24398
24895
|
const timestamp2 = new Date().toISOString();
|
|
24896
|
+
const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
|
|
24897
|
+
if (!slug)
|
|
24898
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
24399
24899
|
return store.upsert("task_lists", {
|
|
24400
24900
|
id: randomUUID3(),
|
|
24401
24901
|
project_id: input.project_id ?? context?.projectId ?? null,
|
|
24402
|
-
slug
|
|
24902
|
+
slug,
|
|
24403
24903
|
name: input.name,
|
|
24404
24904
|
description: input.description ?? null,
|
|
24405
24905
|
metadata: input.metadata ?? {},
|
|
@@ -24411,9 +24911,20 @@ async function createTaskList2(input, store, context) {
|
|
|
24411
24911
|
}
|
|
24412
24912
|
async function updateTaskList2(id, input, store) {
|
|
24413
24913
|
const list = await requireRecord("task_lists", id, store);
|
|
24914
|
+
const patch = definedPatch(input);
|
|
24915
|
+
if (input.slug !== undefined) {
|
|
24916
|
+
const slug = slugifyRaw(input.slug);
|
|
24917
|
+
if (!slug)
|
|
24918
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
24919
|
+
const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === list.project_id && candidate.slug === slug);
|
|
24920
|
+
if (duplicate) {
|
|
24921
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
24922
|
+
}
|
|
24923
|
+
patch.slug = slug;
|
|
24924
|
+
}
|
|
24414
24925
|
return store.upsert("task_lists", {
|
|
24415
24926
|
...list,
|
|
24416
|
-
...
|
|
24927
|
+
...patch,
|
|
24417
24928
|
metadata: input.metadata ?? list.metadata,
|
|
24418
24929
|
updated_at: new Date().toISOString()
|
|
24419
24930
|
});
|
|
@@ -24497,6 +25008,16 @@ async function exportSnapshot(store) {
|
|
|
24497
25008
|
}
|
|
24498
25009
|
async function importSnapshot(snapshot, store, context) {
|
|
24499
25010
|
const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
|
|
25011
|
+
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
25012
|
+
if (result.errors.length > 0)
|
|
25013
|
+
return result;
|
|
25014
|
+
const [existingProjects, existingTaskLists] = await Promise.all([
|
|
25015
|
+
store.list("projects"),
|
|
25016
|
+
store.list("task_lists")
|
|
25017
|
+
]);
|
|
25018
|
+
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
25019
|
+
if (result.errors.length > 0)
|
|
25020
|
+
return result;
|
|
24500
25021
|
const entries = [
|
|
24501
25022
|
...snapshot.tasks.map((row) => ["tasks", row]),
|
|
24502
25023
|
...snapshot.projects.map((row) => ["projects", row]),
|
|
@@ -24567,10 +25088,7 @@ async function generateProjectPrefix(name, store) {
|
|
|
24567
25088
|
return candidate;
|
|
24568
25089
|
}
|
|
24569
25090
|
function slugifyRaw(value) {
|
|
24570
|
-
return value
|
|
24571
|
-
}
|
|
24572
|
-
function slugify2(value) {
|
|
24573
|
-
return slugifyRaw(value) || "todos";
|
|
25091
|
+
return normalizeSlug(value);
|
|
24574
25092
|
}
|
|
24575
25093
|
function normalizePlanSlug2(value) {
|
|
24576
25094
|
const slug = slugifyRaw(value);
|
|
@@ -24624,6 +25142,16 @@ function compareClock(left, right) {
|
|
|
24624
25142
|
function numberValue3(value) {
|
|
24625
25143
|
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
24626
25144
|
}
|
|
25145
|
+
function isPostgresUniqueViolation(error) {
|
|
25146
|
+
return typeof error === "object" && error !== null && error.code === "23505";
|
|
25147
|
+
}
|
|
25148
|
+
function postgresConstraintName(error) {
|
|
25149
|
+
if (typeof error !== "object" || error === null)
|
|
25150
|
+
return "";
|
|
25151
|
+
const candidate = error;
|
|
25152
|
+
const constraint = candidate.constraint ?? candidate.constraint_name;
|
|
25153
|
+
return typeof constraint === "string" ? constraint : "";
|
|
25154
|
+
}
|
|
24627
25155
|
|
|
24628
25156
|
// src/storage/shadow.ts
|
|
24629
25157
|
var SNAPSHOT_TO_OBJECT_TYPE = {
|
|
@@ -24889,6 +25417,19 @@ function createShadowTodosStorageAdapter(options) {
|
|
|
24889
25417
|
mirror.enqueueUpsert("projects", project, context);
|
|
24890
25418
|
return project;
|
|
24891
25419
|
},
|
|
25420
|
+
async rename(id, input, context) {
|
|
25421
|
+
const projectBefore = await local.projects.get(id, context);
|
|
25422
|
+
const cascadeCandidates = projectBefore?.task_list_id ? (await local.taskLists.list(id, context)).filter((list) => list.slug === projectBefore.task_list_id) : [];
|
|
25423
|
+
const result = await local.projects.rename(id, input, context);
|
|
25424
|
+
mirror.enqueueUpsert("projects", result.project, context);
|
|
25425
|
+
for (const candidate of cascadeCandidates) {
|
|
25426
|
+
const changed = await local.taskLists.get(candidate.id, context);
|
|
25427
|
+
if (changed && (changed.slug !== candidate.slug || changed.name !== candidate.name)) {
|
|
25428
|
+
mirror.enqueueUpsert("taskLists", changed, context);
|
|
25429
|
+
}
|
|
25430
|
+
}
|
|
25431
|
+
return result;
|
|
25432
|
+
},
|
|
24892
25433
|
async delete(id, context) {
|
|
24893
25434
|
const deleted = await local.projects.delete(id, context);
|
|
24894
25435
|
if (deleted)
|
|
@@ -28800,10 +29341,10 @@ function bootstrapProject(options = {}, db) {
|
|
|
28800
29341
|
let project = ensureProject(options.name || discovery.projectName, discovery.projectPath, d);
|
|
28801
29342
|
const createdProject = !beforeProject;
|
|
28802
29343
|
if (project.task_list_id !== taskListSlug || options.name && project.name !== options.name) {
|
|
28803
|
-
project =
|
|
29344
|
+
project = renameProject(project.id, {
|
|
28804
29345
|
name: options.name ?? project.name,
|
|
28805
|
-
|
|
28806
|
-
}, d);
|
|
29346
|
+
new_slug: taskListSlug
|
|
29347
|
+
}, d).project;
|
|
28807
29348
|
}
|
|
28808
29349
|
setMachineLocalPath(project.id, discovery.projectPath, d);
|
|
28809
29350
|
const beforeTaskList = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ?").get(project.id, taskListSlug);
|
|
@@ -31751,7 +32292,7 @@ function getTaskLabels(taskId, db) {
|
|
|
31751
32292
|
// src/db/custom-fields.ts
|
|
31752
32293
|
init_database();
|
|
31753
32294
|
var CUSTOM_FIELD_TYPES = ["text", "number", "boolean", "date", "enum"];
|
|
31754
|
-
function
|
|
32295
|
+
function slugify2(name) {
|
|
31755
32296
|
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
31756
32297
|
}
|
|
31757
32298
|
function rowToDef(row) {
|
|
@@ -31772,7 +32313,7 @@ function createCustomFieldDefinition(input, db) {
|
|
|
31772
32313
|
const d = db || getDatabase();
|
|
31773
32314
|
const id = uuid();
|
|
31774
32315
|
const ts = now();
|
|
31775
|
-
const slug =
|
|
32316
|
+
const slug = slugify2(input.name);
|
|
31776
32317
|
d.run(`INSERT INTO custom_field_definitions (
|
|
31777
32318
|
id, project_id, name, slug, field_type, options, required, default_value, sort_order, created_at, updated_at
|
|
31778
32319
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
@@ -31794,7 +32335,7 @@ function getCustomFieldDefinition(idOrSlug, db) {
|
|
|
31794
32335
|
const d = db || getDatabase();
|
|
31795
32336
|
let row = d.query("SELECT * FROM custom_field_definitions WHERE id = ?").get(idOrSlug);
|
|
31796
32337
|
if (!row)
|
|
31797
|
-
row = d.query("SELECT * FROM custom_field_definitions WHERE slug = ?").get(
|
|
32338
|
+
row = d.query("SELECT * FROM custom_field_definitions WHERE slug = ?").get(slugify2(idOrSlug));
|
|
31798
32339
|
return row ? rowToDef(row) : null;
|
|
31799
32340
|
}
|
|
31800
32341
|
function listCustomFieldDefinitions(projectId, db) {
|
|
@@ -36016,7 +36557,7 @@ function searchTasks(options, projectId, taskListId, db) {
|
|
|
36016
36557
|
init_projects();
|
|
36017
36558
|
init_plans();
|
|
36018
36559
|
var SAVED_VIEWS_SCHEMA = "todos.saved_views.v1";
|
|
36019
|
-
function
|
|
36560
|
+
function slugify3(name) {
|
|
36020
36561
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
36021
36562
|
}
|
|
36022
36563
|
function rowToView(row) {
|
|
@@ -36035,7 +36576,7 @@ function createSavedView(input, db) {
|
|
|
36035
36576
|
const d = db || getDatabase();
|
|
36036
36577
|
const id = uuid();
|
|
36037
36578
|
const ts = now();
|
|
36038
|
-
const slug = input.slug ??
|
|
36579
|
+
const slug = input.slug ?? slugify3(input.name);
|
|
36039
36580
|
d.run(`INSERT INTO saved_views (id, name, slug, entity_type, filters, created_at, updated_at)
|
|
36040
36581
|
VALUES (?, ?, ?, ?, ?, ?, ?)`, [id, input.name, slug, input.entity_type ?? "task", JSON.stringify(input.filters ?? {}), ts, ts]);
|
|
36041
36582
|
return getSavedView(id, d);
|
|
@@ -40542,7 +41083,7 @@ function storePath(cwd) {
|
|
|
40542
41083
|
function versionsDir(cwd) {
|
|
40543
41084
|
return join22(storeDir(cwd), "versions");
|
|
40544
41085
|
}
|
|
40545
|
-
function
|
|
41086
|
+
function slugify4(name) {
|
|
40546
41087
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
40547
41088
|
}
|
|
40548
41089
|
function emptyStore2() {
|
|
@@ -40581,7 +41122,7 @@ function createUserScaffold(input, db, cwd) {
|
|
|
40581
41122
|
const store = loadUserScaffoldStore(cwd);
|
|
40582
41123
|
const id = uuid();
|
|
40583
41124
|
const ts = now();
|
|
40584
|
-
const slug =
|
|
41125
|
+
const slug = slugify4(input.name);
|
|
40585
41126
|
if (Object.values(store.scaffolds).some((s) => s.slug === slug)) {
|
|
40586
41127
|
throw new Error(`Scaffold slug already exists: ${slug}`);
|
|
40587
41128
|
}
|
|
@@ -40618,8 +41159,8 @@ function updateUserScaffold(idOrSlug, updates, db, cwd) {
|
|
|
40618
41159
|
if (!existing)
|
|
40619
41160
|
throw new Error(`Scaffold not found: ${idOrSlug}`);
|
|
40620
41161
|
snapshotVersion(existing, cwd);
|
|
40621
|
-
if (updates.name &&
|
|
40622
|
-
const newSlug =
|
|
41162
|
+
if (updates.name && slugify4(updates.name) !== existing.slug) {
|
|
41163
|
+
const newSlug = slugify4(updates.name);
|
|
40623
41164
|
if (Object.values(store.scaffolds).some((s) => s.id !== existing.id && s.slug === newSlug)) {
|
|
40624
41165
|
throw new Error(`Scaffold slug conflict: ${newSlug}`);
|
|
40625
41166
|
}
|