@hasna/todos 0.11.87 → 0.11.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/cloud-router.d.ts +13 -3
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/agent-commands.d.ts.map +1 -1
- package/dist/cli/commands/project-commands.d.ts.map +1 -1
- package/dist/cli/commands/task-commands.d.ts.map +1 -1
- package/dist/cli/index.js +1008 -125
- package/dist/contracts.js +301 -72
- package/dist/db/projects.d.ts +2 -2
- package/dist/db/projects.d.ts.map +1 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/slug-claims.d.ts +11 -0
- package/dist/db/slug-claims.d.ts.map +1 -0
- package/dist/db/task-lists.d.ts.map +1 -1
- package/dist/index.js +641 -100
- package/dist/lib/slugs.d.ts +19 -0
- package/dist/lib/slugs.d.ts.map +1 -0
- package/dist/mcp/index.js +897 -102
- package/dist/registry.js +301 -72
- package/dist/release-provenance.json +3 -3
- package/dist/sdk/index.js +56 -0
- package/dist/sdk/v1.generated.d.ts +79 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +2 -0
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +906 -108
- package/dist/server/openapi.d.ts +464 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +3 -2
- package/dist/storage/interfaces.d.ts.map +1 -1
- package/dist/storage/local-sqlite.d.ts.map +1 -1
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/postgres-sync.d.ts +30 -0
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage/shadow.d.ts.map +1 -1
- package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +635 -88
- package/dist/types/index.d.ts +19 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -702,7 +702,7 @@ var init_cloud_client = __esm(() => {
|
|
|
702
702
|
});
|
|
703
703
|
|
|
704
704
|
// src/types/index.ts
|
|
705
|
-
var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
|
|
705
|
+
var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
|
|
706
706
|
var init_types = __esm(() => {
|
|
707
707
|
TASK_STATUSES = [
|
|
708
708
|
"pending",
|
|
@@ -745,6 +745,14 @@ var init_types = __esm(() => {
|
|
|
745
745
|
this.name = "ProjectNotFoundError";
|
|
746
746
|
}
|
|
747
747
|
};
|
|
748
|
+
ResourceConflictError = class ResourceConflictError extends Error {
|
|
749
|
+
code;
|
|
750
|
+
constructor(code, message) {
|
|
751
|
+
super(message);
|
|
752
|
+
this.code = code;
|
|
753
|
+
this.name = "ResourceConflictError";
|
|
754
|
+
}
|
|
755
|
+
};
|
|
748
756
|
PlanNotFoundError = class PlanNotFoundError extends Error {
|
|
749
757
|
planId;
|
|
750
758
|
static code = "PLAN_NOT_FOUND";
|
|
@@ -823,6 +831,77 @@ var init_types = __esm(() => {
|
|
|
823
831
|
};
|
|
824
832
|
});
|
|
825
833
|
|
|
834
|
+
// src/lib/slugs.ts
|
|
835
|
+
function normalizeSlug(value) {
|
|
836
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
837
|
+
}
|
|
838
|
+
function isCanonicalSlug(value) {
|
|
839
|
+
return typeof value === "string" && value.length > 0 && normalizeSlug(value) === value;
|
|
840
|
+
}
|
|
841
|
+
function isValidTaskListProjectScope(value) {
|
|
842
|
+
return value === undefined || value === null || typeof value === "string" && value.trim().length > 0;
|
|
843
|
+
}
|
|
844
|
+
function validateSnapshotRoutingRecords(projects, taskLists) {
|
|
845
|
+
const errors = [];
|
|
846
|
+
const projectSlugs = new Map;
|
|
847
|
+
const taskListSlugs = new Map;
|
|
848
|
+
for (const project of projects) {
|
|
849
|
+
if (!isCanonicalSlug(project.task_list_id)) {
|
|
850
|
+
errors.push(`project ${project.id}: task_list_id must be non-empty canonical kebab-case`);
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
const existing = projectSlugs.get(project.task_list_id);
|
|
854
|
+
if (projectSlugs.has(project.task_list_id)) {
|
|
855
|
+
errors.push(`project ${project.id}: task_list_id duplicates project ${existing}: ${project.task_list_id}`);
|
|
856
|
+
} else {
|
|
857
|
+
projectSlugs.set(project.task_list_id, project.id);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
for (const taskList of taskLists) {
|
|
861
|
+
if (!isCanonicalSlug(taskList.slug)) {
|
|
862
|
+
errors.push(`task list ${taskList.id}: slug must be non-empty canonical kebab-case`);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if (!isValidTaskListProjectScope(taskList.project_id)) {
|
|
866
|
+
errors.push(`task list ${taskList.id}: project_id must be null, missing, or a non-empty string`);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
const scope = taskList.project_id ?? null;
|
|
870
|
+
const scopedSlugs = taskListSlugs.get(scope) ?? new Map;
|
|
871
|
+
const existing = scopedSlugs.get(taskList.slug);
|
|
872
|
+
if (scopedSlugs.has(taskList.slug)) {
|
|
873
|
+
errors.push(`task list ${taskList.id}: slug duplicates task list ${existing} in the same scope: ${taskList.slug}`);
|
|
874
|
+
} else {
|
|
875
|
+
scopedSlugs.set(taskList.slug, taskList.id);
|
|
876
|
+
taskListSlugs.set(scope, scopedSlugs);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return errors;
|
|
880
|
+
}
|
|
881
|
+
function validateSnapshotRoutingDestinationConflicts(projects, taskLists, existingProjects, existingTaskLists) {
|
|
882
|
+
const errors = [];
|
|
883
|
+
for (const project of projects) {
|
|
884
|
+
const current = existingProjects.find((candidate) => candidate.id === project.id);
|
|
885
|
+
if (current?.task_list_id === project.task_list_id)
|
|
886
|
+
continue;
|
|
887
|
+
const conflict = existingProjects.find((candidate) => candidate.id !== project.id && candidate.task_list_id === project.task_list_id);
|
|
888
|
+
if (conflict) {
|
|
889
|
+
errors.push(`project ${project.id}: task_list_id conflicts with existing project ${conflict.id}: ${String(project.task_list_id)}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
for (const taskList of taskLists) {
|
|
893
|
+
const projectId = taskList.project_id ?? null;
|
|
894
|
+
const current = existingTaskLists.find((candidate) => candidate.id === taskList.id);
|
|
895
|
+
if ((current?.project_id ?? null) === projectId && current?.slug === taskList.slug)
|
|
896
|
+
continue;
|
|
897
|
+
const conflict = existingTaskLists.find((candidate) => candidate.id !== taskList.id && (candidate.project_id ?? null) === projectId && candidate.slug === taskList.slug);
|
|
898
|
+
if (conflict) {
|
|
899
|
+
errors.push(`task list ${taskList.id}: slug conflicts with existing task list ${conflict.id} in the same scope: ${String(taskList.slug)}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return errors;
|
|
903
|
+
}
|
|
904
|
+
|
|
826
905
|
// src/storage/postgres-sync.ts
|
|
827
906
|
function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE, cursorTableName = DEFAULT_TODOS_POSTGRES_CURSOR_TABLE) {
|
|
828
907
|
assertSafeIdentifier(tableName);
|
|
@@ -852,6 +931,91 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
852
931
|
)`
|
|
853
932
|
];
|
|
854
933
|
}
|
|
934
|
+
function postgresTodosScopedSlugPreflightSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
935
|
+
assertSafeIdentifier(tableName);
|
|
936
|
+
return `/* todos:scoped-slug-duplicate-audit */ WITH candidates AS (
|
|
937
|
+
SELECT service, object_type, COALESCE(payload->>'project_id', '') AS scope,
|
|
938
|
+
jsonb_typeof(payload->'project_id') AS scope_type,
|
|
939
|
+
payload->>'slug' AS slug, jsonb_typeof(payload->'slug') AS slug_type, object_id
|
|
940
|
+
FROM ${tableName}
|
|
941
|
+
WHERE object_type = 'task_lists' AND deleted_at IS NULL
|
|
942
|
+
UNION ALL
|
|
943
|
+
SELECT service, object_type, '' AS scope, NULL::text AS scope_type, payload->>'task_list_id' AS slug,
|
|
944
|
+
jsonb_typeof(payload->'task_list_id') AS slug_type, object_id
|
|
945
|
+
FROM ${tableName}
|
|
946
|
+
WHERE object_type = 'projects' AND deleted_at IS NULL
|
|
947
|
+
), annotated AS (
|
|
948
|
+
SELECT *, trim(both '-' from regexp_replace(lower(COALESCE(slug, '')), '[^a-z0-9]+', '-', 'g')) AS normalized_slug
|
|
949
|
+
FROM candidates
|
|
950
|
+
), invalid AS (
|
|
951
|
+
SELECT service, object_type, scope, COALESCE(slug, '<null>') AS slug,
|
|
952
|
+
ARRAY[object_id] AS object_ids, 1::integer AS duplicate_count, 'invalid'::text AS issue
|
|
953
|
+
FROM annotated
|
|
954
|
+
WHERE slug_type IS DISTINCT FROM 'string'
|
|
955
|
+
OR slug IS NULL OR slug = '' OR normalized_slug = '' OR slug IS DISTINCT FROM normalized_slug
|
|
956
|
+
OR (object_type = 'task_lists' AND (
|
|
957
|
+
(scope_type IS NOT NULL AND scope_type NOT IN ('string', 'null'))
|
|
958
|
+
OR (scope_type = 'string' AND btrim(scope) = '')
|
|
959
|
+
))
|
|
960
|
+
), duplicates AS (
|
|
961
|
+
SELECT service, object_type, scope, slug,
|
|
962
|
+
array_agg(object_id ORDER BY object_id) AS object_ids,
|
|
963
|
+
count(*)::integer AS duplicate_count, 'duplicate'::text AS issue
|
|
964
|
+
FROM annotated
|
|
965
|
+
WHERE slug = normalized_slug AND slug <> ''
|
|
966
|
+
GROUP BY service, object_type, scope, slug
|
|
967
|
+
HAVING count(*) > 1
|
|
968
|
+
) SELECT * FROM invalid
|
|
969
|
+
UNION ALL SELECT * FROM duplicates
|
|
970
|
+
ORDER BY service, object_type, scope, slug`;
|
|
971
|
+
}
|
|
972
|
+
function postgresTodosScopedSlugUniqueIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
973
|
+
assertSafeIdentifier(tableName);
|
|
974
|
+
return [
|
|
975
|
+
`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_task_list_scope_slug_uidx
|
|
976
|
+
ON ${tableName} (service, COALESCE(payload->>'project_id', ''), (payload->>'slug'))
|
|
977
|
+
WHERE object_type = 'task_lists' AND deleted_at IS NULL AND COALESCE(payload->>'slug', '') <> ''`,
|
|
978
|
+
`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_project_task_list_slug_uidx
|
|
979
|
+
ON ${tableName} (service, (payload->>'task_list_id'))
|
|
980
|
+
WHERE object_type = 'projects' AND deleted_at IS NULL AND COALESCE(payload->>'task_list_id', '') <> ''`
|
|
981
|
+
];
|
|
982
|
+
}
|
|
983
|
+
function postgresTodosScopedSlugIndexStatusSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
984
|
+
assertSafeIdentifier(tableName);
|
|
985
|
+
return `/* todos:scoped-slug-index-status */ SELECT index_class.relname AS index_name,
|
|
986
|
+
index_meta.indisvalid AS is_valid, index_meta.indisready AS is_ready
|
|
987
|
+
FROM pg_index index_meta
|
|
988
|
+
JOIN pg_class index_class ON index_class.oid = index_meta.indexrelid
|
|
989
|
+
WHERE index_meta.indrelid = to_regclass('${tableName}')
|
|
990
|
+
AND index_class.relname IN (
|
|
991
|
+
'${tableName}_task_list_scope_slug_uidx',
|
|
992
|
+
'${tableName}_project_task_list_slug_uidx'
|
|
993
|
+
)`;
|
|
994
|
+
}
|
|
995
|
+
async function ensurePostgresScopedSlugUniqueIndexes(client, tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
996
|
+
const audit = await client.query(postgresTodosScopedSlugPreflightSql(tableName));
|
|
997
|
+
if (audit.rows.length > 0)
|
|
998
|
+
throw new PostgresScopedSlugMigrationConflictError(audit.rows);
|
|
999
|
+
for (const sql of postgresTodosScopedSlugUniqueIndexSql(tableName)) {
|
|
1000
|
+
try {
|
|
1001
|
+
await client.query(sql);
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
const indexName = sql.match(/INDEX CONCURRENTLY IF NOT EXISTS ([a-zA-Z0-9_]+)/)?.[1] ?? "unknown_index";
|
|
1004
|
+
throw new PostgresScopedSlugIndexBuildError(indexName, error);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const expected = new Set([
|
|
1008
|
+
`${tableName}_task_list_scope_slug_uidx`,
|
|
1009
|
+
`${tableName}_project_task_list_slug_uidx`
|
|
1010
|
+
]);
|
|
1011
|
+
const status = await client.query(postgresTodosScopedSlugIndexStatusSql(tableName));
|
|
1012
|
+
for (const indexName of expected) {
|
|
1013
|
+
const row = status.rows.find((candidate) => candidate.index_name === indexName);
|
|
1014
|
+
if (!row?.is_valid || !row.is_ready) {
|
|
1015
|
+
throw new PostgresScopedSlugIndexBuildError(indexName, new Error("index is missing, invalid, or not ready"));
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
855
1019
|
function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
856
1020
|
assertSafeIdentifier(tableName);
|
|
857
1021
|
return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
|
|
@@ -880,9 +1044,31 @@ class PostgresTodosSyncStore {
|
|
|
880
1044
|
}
|
|
881
1045
|
}
|
|
882
1046
|
async pushSnapshot(snapshot, context = {}) {
|
|
1047
|
+
const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
|
|
1048
|
+
if (routingErrors.length > 0) {
|
|
1049
|
+
throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
|
|
1050
|
+
}
|
|
1051
|
+
const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
|
|
1052
|
+
FROM ${this.tableName}
|
|
1053
|
+
WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
|
|
1054
|
+
const existingProjects = [];
|
|
1055
|
+
const existingTaskLists = [];
|
|
1056
|
+
for (const row of existing.rows) {
|
|
1057
|
+
const payload = payloadRecord(row.payload);
|
|
1058
|
+
if (row.object_type === "projects")
|
|
1059
|
+
existingProjects.push(payload);
|
|
1060
|
+
if (row.object_type === "task_lists")
|
|
1061
|
+
existingTaskLists.push(payload);
|
|
1062
|
+
}
|
|
1063
|
+
const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
|
|
1064
|
+
if (destinationErrors.length > 0) {
|
|
1065
|
+
throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
|
|
1066
|
+
}
|
|
883
1067
|
const result = { records: 0, objectTypes: {} };
|
|
884
1068
|
const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
|
|
885
1069
|
for (const entry of snapshotEntries(snapshot)) {
|
|
1070
|
+
if (entry.deletedAt === null)
|
|
1071
|
+
assertCanonicalScopedSlugEntry(entry);
|
|
886
1072
|
await this.client.query(`INSERT INTO ${this.tableName} (
|
|
887
1073
|
service, object_type, object_id, payload, updated_at,
|
|
888
1074
|
deleted_at, source_machine_id, version
|
|
@@ -961,6 +1147,22 @@ function snapshotEntries(snapshot) {
|
|
|
961
1147
|
}))
|
|
962
1148
|
];
|
|
963
1149
|
}
|
|
1150
|
+
function assertCanonicalScopedSlugEntry(entry) {
|
|
1151
|
+
if (!entry.payload || typeof entry.payload !== "object" || Array.isArray(entry.payload))
|
|
1152
|
+
return;
|
|
1153
|
+
const payload = entry.payload;
|
|
1154
|
+
if (entry.type === "projects" && !isCanonicalSlug(payload["task_list_id"])) {
|
|
1155
|
+
throw new Error("Invalid project task-list slug \u2014 sync requires non-empty canonical kebab-case");
|
|
1156
|
+
}
|
|
1157
|
+
if (entry.type === "task_lists") {
|
|
1158
|
+
if (!isCanonicalSlug(payload["slug"])) {
|
|
1159
|
+
throw new Error("Invalid task-list slug \u2014 sync requires non-empty canonical kebab-case");
|
|
1160
|
+
}
|
|
1161
|
+
if (!isValidTaskListProjectScope(payload["project_id"])) {
|
|
1162
|
+
throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
964
1166
|
function entry(type, payload, fallbackUpdatedAt) {
|
|
965
1167
|
const id = payload["id"];
|
|
966
1168
|
if (typeof id !== "string" || !id)
|
|
@@ -1043,7 +1245,26 @@ function assertSafeIdentifier(value) {
|
|
|
1043
1245
|
if (!/^[a-z_][a-z0-9_]*$/i.test(value))
|
|
1044
1246
|
throw new Error(`Unsafe Postgres identifier: ${value}`);
|
|
1045
1247
|
}
|
|
1046
|
-
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
|
|
1248
|
+
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors", PostgresScopedSlugMigrationConflictError, PostgresScopedSlugIndexBuildError;
|
|
1249
|
+
var init_postgres_sync = __esm(() => {
|
|
1250
|
+
PostgresScopedSlugMigrationConflictError = class PostgresScopedSlugMigrationConflictError extends Error {
|
|
1251
|
+
conflicts;
|
|
1252
|
+
constructor(conflicts) {
|
|
1253
|
+
const preview = conflicts.slice(0, 5).map((conflict) => `${conflict.object_type}:${conflict.scope || "global"}:${conflict.slug} [${conflict.object_ids.join(", ")}]`).join("; ");
|
|
1254
|
+
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.");
|
|
1255
|
+
this.conflicts = conflicts;
|
|
1256
|
+
this.name = "PostgresScopedSlugMigrationConflictError";
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
PostgresScopedSlugIndexBuildError = class PostgresScopedSlugIndexBuildError extends Error {
|
|
1260
|
+
index_name;
|
|
1261
|
+
constructor(index_name, cause) {
|
|
1262
|
+
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 });
|
|
1263
|
+
this.index_name = index_name;
|
|
1264
|
+
this.name = "PostgresScopedSlugIndexBuildError";
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
});
|
|
1047
1268
|
|
|
1048
1269
|
// src/lib/sync-utils.ts
|
|
1049
1270
|
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, readdirSync, statSync, writeFileSync } from "fs";
|
|
@@ -1299,6 +1520,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
1299
1520
|
getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
|
|
1300
1521
|
list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
|
|
1301
1522
|
update: (id, input) => updateProject(id, input, store),
|
|
1523
|
+
rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
|
|
1302
1524
|
delete: (id, context) => store.delete("projects", id, context)
|
|
1303
1525
|
},
|
|
1304
1526
|
plans: {
|
|
@@ -1536,9 +1758,22 @@ class PostgresJsonRecordStore {
|
|
|
1536
1758
|
});
|
|
1537
1759
|
}
|
|
1538
1760
|
async upsert(type, value, context = {}) {
|
|
1761
|
+
if (type === "projects" && !isCanonicalSlug(value.task_list_id)) {
|
|
1762
|
+
throw new Error("Invalid project task-list slug \u2014 imports require non-empty canonical kebab-case");
|
|
1763
|
+
}
|
|
1764
|
+
if (type === "task_lists") {
|
|
1765
|
+
if (!isCanonicalSlug(value.slug)) {
|
|
1766
|
+
throw new Error("Invalid task-list slug \u2014 imports require non-empty canonical kebab-case");
|
|
1767
|
+
}
|
|
1768
|
+
if (!isValidTaskListProjectScope(value.project_id)) {
|
|
1769
|
+
throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1539
1772
|
await this.ensureSchema();
|
|
1540
1773
|
const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
|
|
1541
|
-
|
|
1774
|
+
let result;
|
|
1775
|
+
try {
|
|
1776
|
+
result = await this.options.client.query(`INSERT INTO ${this.tableName} (
|
|
1542
1777
|
service, object_type, object_id, payload, updated_at,
|
|
1543
1778
|
deleted_at, source_machine_id, version
|
|
1544
1779
|
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, $7)
|
|
@@ -1553,14 +1788,23 @@ class PostgresJsonRecordStore {
|
|
|
1553
1788
|
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
1554
1789
|
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
|
|
1555
1790
|
RETURNING object_id`, [
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1791
|
+
this.service,
|
|
1792
|
+
type,
|
|
1793
|
+
value.id,
|
|
1794
|
+
jsonbParam(value),
|
|
1795
|
+
updatedAt,
|
|
1796
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
1797
|
+
numberValue2(value.version)
|
|
1798
|
+
]);
|
|
1799
|
+
} catch (error) {
|
|
1800
|
+
if (type === "task_lists" && isPostgresUniqueViolation(error)) {
|
|
1801
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${String(value.slug ?? "")}" already exists in this scope`);
|
|
1802
|
+
}
|
|
1803
|
+
if (type === "projects" && isPostgresUniqueViolation(error)) {
|
|
1804
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${String(value.task_list_id ?? "")}" already exists`);
|
|
1805
|
+
}
|
|
1806
|
+
throw error;
|
|
1807
|
+
}
|
|
1564
1808
|
if (result.rows.length === 0) {
|
|
1565
1809
|
const current = await this.get(type, value.id);
|
|
1566
1810
|
if (current)
|
|
@@ -1568,6 +1812,93 @@ class PostgresJsonRecordStore {
|
|
|
1568
1812
|
}
|
|
1569
1813
|
return value;
|
|
1570
1814
|
}
|
|
1815
|
+
async renameProject(id, newSlug, name, context = {}) {
|
|
1816
|
+
await this.ensureSchema();
|
|
1817
|
+
const normalizedSlug = slugifyRaw(newSlug);
|
|
1818
|
+
if (!normalizedSlug)
|
|
1819
|
+
throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
|
|
1820
|
+
const timestamp = new Date().toISOString();
|
|
1821
|
+
try {
|
|
1822
|
+
const result = await this.options.client.query(`/* todos:rename-project-atomic */ WITH target AS (
|
|
1823
|
+
SELECT payload, payload->>'task_list_id' AS old_slug
|
|
1824
|
+
FROM ${this.tableName}
|
|
1825
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL
|
|
1826
|
+
FOR UPDATE
|
|
1827
|
+
), project_conflict AS (
|
|
1828
|
+
SELECT 1 FROM ${this.tableName}
|
|
1829
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
1830
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3 LIMIT 1
|
|
1831
|
+
), task_list_conflict AS (
|
|
1832
|
+
SELECT 1 FROM ${this.tableName} r, target
|
|
1833
|
+
WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
|
|
1834
|
+
AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = $3
|
|
1835
|
+
AND r.payload->>'slug' IS DISTINCT FROM target.old_slug LIMIT 1
|
|
1836
|
+
), updated_lists AS (
|
|
1837
|
+
UPDATE ${this.tableName} r SET
|
|
1838
|
+
payload = r.payload || jsonb_build_object('slug', $3::text, 'updated_at', $5::text)
|
|
1839
|
+
|| CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
|
|
1840
|
+
updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
|
|
1841
|
+
source_machine_id = COALESCE($6, r.source_machine_id)
|
|
1842
|
+
FROM target
|
|
1843
|
+
WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
|
|
1844
|
+
AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = target.old_slug
|
|
1845
|
+
AND NOT EXISTS (SELECT 1 FROM project_conflict)
|
|
1846
|
+
AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
|
|
1847
|
+
AND (target.old_slug IS DISTINCT FROM $3
|
|
1848
|
+
OR ($4::text IS NOT NULL AND r.payload->>'name' IS DISTINCT FROM $4))
|
|
1849
|
+
RETURNING 1
|
|
1850
|
+
), updated_project AS (
|
|
1851
|
+
UPDATE ${this.tableName} r SET
|
|
1852
|
+
payload = r.payload || jsonb_build_object('task_list_id', $3::text, 'updated_at', $5::text)
|
|
1853
|
+
|| CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
|
|
1854
|
+
updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
|
|
1855
|
+
source_machine_id = COALESCE($6, r.source_machine_id)
|
|
1856
|
+
FROM target
|
|
1857
|
+
WHERE r.service = $1 AND r.object_type = 'projects' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
1858
|
+
AND NOT EXISTS (SELECT 1 FROM project_conflict)
|
|
1859
|
+
AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
|
|
1860
|
+
AND (target.old_slug IS DISTINCT FROM $3
|
|
1861
|
+
OR ($4::text IS NOT NULL AND target.payload->>'name' IS DISTINCT FROM $4))
|
|
1862
|
+
RETURNING r.payload
|
|
1863
|
+
) SELECT
|
|
1864
|
+
EXISTS (SELECT 1 FROM target) AS found,
|
|
1865
|
+
EXISTS (SELECT 1 FROM project_conflict) AS project_conflict,
|
|
1866
|
+
EXISTS (SELECT 1 FROM task_list_conflict) AS task_list_conflict,
|
|
1867
|
+
COALESCE((SELECT payload FROM updated_project), (SELECT payload FROM target)) AS project,
|
|
1868
|
+
(SELECT count(*) FROM updated_lists) AS task_lists_updated`, [this.service, id, normalizedSlug, name ?? null, timestamp, this.machineId(context)]);
|
|
1869
|
+
const row = result.rows[0];
|
|
1870
|
+
if (!row?.found)
|
|
1871
|
+
throw new ProjectNotFoundError(id);
|
|
1872
|
+
if (row.project_conflict) {
|
|
1873
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
|
|
1874
|
+
}
|
|
1875
|
+
if (row.task_list_conflict) {
|
|
1876
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
|
|
1877
|
+
}
|
|
1878
|
+
return {
|
|
1879
|
+
project: payloadRecord2(row.project),
|
|
1880
|
+
task_lists_updated: Number(row.task_lists_updated)
|
|
1881
|
+
};
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
if (isPostgresUniqueViolation(error)) {
|
|
1884
|
+
const constraintName = postgresConstraintName(error);
|
|
1885
|
+
let projectConflict = constraintName.includes("project_task_list_slug_uidx");
|
|
1886
|
+
if (!constraintName) {
|
|
1887
|
+
const conflict = await this.options.client.query(`/* todos:classify-project-rename-conflict */ SELECT EXISTS (
|
|
1888
|
+
SELECT 1 FROM ${this.tableName}
|
|
1889
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
1890
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3
|
|
1891
|
+
) AS project_conflict`, [this.service, id, normalizedSlug]);
|
|
1892
|
+
projectConflict = Boolean(conflict.rows[0]?.project_conflict);
|
|
1893
|
+
}
|
|
1894
|
+
if (projectConflict) {
|
|
1895
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
|
|
1896
|
+
}
|
|
1897
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
|
|
1898
|
+
}
|
|
1899
|
+
throw error;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1571
1902
|
async incrementProjectTaskCounter(projectId, _context = {}) {
|
|
1572
1903
|
await this.ensureSchema();
|
|
1573
1904
|
const result = await this.options.client.query(`UPDATE ${this.tableName}
|
|
@@ -1987,12 +2318,16 @@ async function getChangedSince(since, filters, store) {
|
|
|
1987
2318
|
}
|
|
1988
2319
|
async function createProject(input, store, context) {
|
|
1989
2320
|
const timestamp = new Date().toISOString();
|
|
2321
|
+
const derivedSlug = slugifyRaw(input.name);
|
|
2322
|
+
const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
|
|
2323
|
+
if (!derivedSlug || !taskListId)
|
|
2324
|
+
throw new Error("Project name and task-list slug must be non-empty");
|
|
1990
2325
|
const project = {
|
|
1991
2326
|
id: randomUUID(),
|
|
1992
2327
|
name: input.name,
|
|
1993
2328
|
path: input.path,
|
|
1994
2329
|
description: input.description ?? null,
|
|
1995
|
-
task_list_id:
|
|
2330
|
+
task_list_id: taskListId,
|
|
1996
2331
|
task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
|
|
1997
2332
|
task_counter: 0,
|
|
1998
2333
|
created_at: timestamp,
|
|
@@ -2003,6 +2338,9 @@ async function createProject(input, store, context) {
|
|
|
2003
2338
|
return store.upsert("projects", project, context);
|
|
2004
2339
|
}
|
|
2005
2340
|
async function updateProject(id, input, store) {
|
|
2341
|
+
if ("task_list_id" in input) {
|
|
2342
|
+
throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
|
|
2343
|
+
}
|
|
2006
2344
|
const project = await requireRecord("projects", id, store);
|
|
2007
2345
|
const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
|
|
2008
2346
|
return store.upsert("projects", updated);
|
|
@@ -2111,10 +2449,13 @@ async function releaseAgent(idOrName, sessionId, store, context) {
|
|
|
2111
2449
|
}
|
|
2112
2450
|
async function createTaskList(input, store, context) {
|
|
2113
2451
|
const timestamp = new Date().toISOString();
|
|
2452
|
+
const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
|
|
2453
|
+
if (!slug)
|
|
2454
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
2114
2455
|
return store.upsert("task_lists", {
|
|
2115
2456
|
id: randomUUID(),
|
|
2116
2457
|
project_id: input.project_id ?? context?.projectId ?? null,
|
|
2117
|
-
slug
|
|
2458
|
+
slug,
|
|
2118
2459
|
name: input.name,
|
|
2119
2460
|
description: input.description ?? null,
|
|
2120
2461
|
metadata: input.metadata ?? {},
|
|
@@ -2126,9 +2467,20 @@ async function createTaskList(input, store, context) {
|
|
|
2126
2467
|
}
|
|
2127
2468
|
async function updateTaskList(id, input, store) {
|
|
2128
2469
|
const list = await requireRecord("task_lists", id, store);
|
|
2470
|
+
const patch = definedPatch(input);
|
|
2471
|
+
if (input.slug !== undefined) {
|
|
2472
|
+
const slug = slugifyRaw(input.slug);
|
|
2473
|
+
if (!slug)
|
|
2474
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
2475
|
+
const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === list.project_id && candidate.slug === slug);
|
|
2476
|
+
if (duplicate) {
|
|
2477
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
2478
|
+
}
|
|
2479
|
+
patch.slug = slug;
|
|
2480
|
+
}
|
|
2129
2481
|
return store.upsert("task_lists", {
|
|
2130
2482
|
...list,
|
|
2131
|
-
...
|
|
2483
|
+
...patch,
|
|
2132
2484
|
metadata: input.metadata ?? list.metadata,
|
|
2133
2485
|
updated_at: new Date().toISOString()
|
|
2134
2486
|
});
|
|
@@ -2212,6 +2564,16 @@ async function exportSnapshot(store) {
|
|
|
2212
2564
|
}
|
|
2213
2565
|
async function importSnapshot(snapshot, store, context) {
|
|
2214
2566
|
const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
|
|
2567
|
+
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
2568
|
+
if (result.errors.length > 0)
|
|
2569
|
+
return result;
|
|
2570
|
+
const [existingProjects, existingTaskLists] = await Promise.all([
|
|
2571
|
+
store.list("projects"),
|
|
2572
|
+
store.list("task_lists")
|
|
2573
|
+
]);
|
|
2574
|
+
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
2575
|
+
if (result.errors.length > 0)
|
|
2576
|
+
return result;
|
|
2215
2577
|
const entries = [
|
|
2216
2578
|
...snapshot.tasks.map((row) => ["tasks", row]),
|
|
2217
2579
|
...snapshot.projects.map((row) => ["projects", row]),
|
|
@@ -2282,10 +2644,7 @@ async function generateProjectPrefix(name, store) {
|
|
|
2282
2644
|
return candidate;
|
|
2283
2645
|
}
|
|
2284
2646
|
function slugifyRaw(value) {
|
|
2285
|
-
return value
|
|
2286
|
-
}
|
|
2287
|
-
function slugify(value) {
|
|
2288
|
-
return slugifyRaw(value) || "todos";
|
|
2647
|
+
return normalizeSlug(value);
|
|
2289
2648
|
}
|
|
2290
2649
|
function normalizePlanSlug(value) {
|
|
2291
2650
|
const slug = slugifyRaw(value);
|
|
@@ -2339,9 +2698,20 @@ function compareClock(left, right) {
|
|
|
2339
2698
|
function numberValue2(value) {
|
|
2340
2699
|
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
2341
2700
|
}
|
|
2701
|
+
function isPostgresUniqueViolation(error) {
|
|
2702
|
+
return typeof error === "object" && error !== null && error.code === "23505";
|
|
2703
|
+
}
|
|
2704
|
+
function postgresConstraintName(error) {
|
|
2705
|
+
if (typeof error !== "object" || error === null)
|
|
2706
|
+
return "";
|
|
2707
|
+
const candidate = error;
|
|
2708
|
+
const constraint = candidate.constraint ?? candidate.constraint_name;
|
|
2709
|
+
return typeof constraint === "string" ? constraint : "";
|
|
2710
|
+
}
|
|
2342
2711
|
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";
|
|
2343
2712
|
var init_postgres_adapter = __esm(() => {
|
|
2344
2713
|
init_types();
|
|
2714
|
+
init_postgres_sync();
|
|
2345
2715
|
init_redaction();
|
|
2346
2716
|
});
|
|
2347
2717
|
|
|
@@ -2448,6 +2818,7 @@ function isCommentRedactionBackfillComplete(result) {
|
|
|
2448
2818
|
var COMMENT_REDACTION_BACKFILL_CONFIRMATION = "REDACT_STORED_TODOS_COMMENTS";
|
|
2449
2819
|
var init_comment_redaction_backfill = __esm(() => {
|
|
2450
2820
|
init_redaction();
|
|
2821
|
+
init_postgres_sync();
|
|
2451
2822
|
});
|
|
2452
2823
|
|
|
2453
2824
|
// src/server/cloud.ts
|
|
@@ -2461,6 +2832,7 @@ __export(exports_cloud, {
|
|
|
2461
2832
|
getCloudVerifier: () => getCloudVerifier,
|
|
2462
2833
|
getCloudStorageAdapter: () => getCloudStorageAdapter,
|
|
2463
2834
|
getApiKeyStore: () => getApiKeyStore,
|
|
2835
|
+
ensureCloudScopedSlugUniqueIndexes: () => ensureCloudScopedSlugUniqueIndexes,
|
|
2464
2836
|
ensureCloudSchema: () => ensureCloudSchema,
|
|
2465
2837
|
ensureCloudCommentCursorIndex: () => ensureCloudCommentCursorIndex,
|
|
2466
2838
|
closeCloud: () => closeCloud,
|
|
@@ -2545,6 +2917,9 @@ async function ensureCloudSchema() {
|
|
|
2545
2917
|
async function ensureCloudCommentCursorIndex() {
|
|
2546
2918
|
await getClient().query(postgresTodosCommentCursorIndexSql());
|
|
2547
2919
|
}
|
|
2920
|
+
async function ensureCloudScopedSlugUniqueIndexes() {
|
|
2921
|
+
await ensurePostgresScopedSlugUniqueIndexes(getClient());
|
|
2922
|
+
}
|
|
2548
2923
|
async function normalizeCloudPayloads() {
|
|
2549
2924
|
const client = getClient();
|
|
2550
2925
|
const res = await client.query(`UPDATE todos_sync_records
|
|
@@ -2576,6 +2951,7 @@ var init_cloud = __esm(() => {
|
|
|
2576
2951
|
init_auth();
|
|
2577
2952
|
init_cloud_client();
|
|
2578
2953
|
init_postgres_adapter();
|
|
2954
|
+
init_postgres_sync();
|
|
2579
2955
|
init_comment_redaction_backfill();
|
|
2580
2956
|
});
|
|
2581
2957
|
|
|
@@ -4288,6 +4664,95 @@ function ensureSchema(db) {
|
|
|
4288
4664
|
)`);
|
|
4289
4665
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_project ON project_machine_paths(project_id)");
|
|
4290
4666
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_machine ON project_machine_paths(machine_id)");
|
|
4667
|
+
ensureTable("canonical_slug_claims", `
|
|
4668
|
+
CREATE TABLE canonical_slug_claims (
|
|
4669
|
+
kind TEXT NOT NULL CHECK(kind IN ('project', 'task_list')),
|
|
4670
|
+
scope_key TEXT NOT NULL,
|
|
4671
|
+
slug TEXT NOT NULL,
|
|
4672
|
+
object_id TEXT NOT NULL,
|
|
4673
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
4674
|
+
PRIMARY KEY (kind, scope_key, slug)
|
|
4675
|
+
)`);
|
|
4676
|
+
ensureIndex("CREATE INDEX IF NOT EXISTS idx_canonical_slug_claims_object ON canonical_slug_claims(kind, object_id)");
|
|
4677
|
+
ensureColumn("projects", "task_list_id", "TEXT");
|
|
4678
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_insert
|
|
4679
|
+
BEFORE INSERT ON projects
|
|
4680
|
+
WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
|
|
4681
|
+
BEGIN
|
|
4682
|
+
SELECT CASE WHEN EXISTS (
|
|
4683
|
+
SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
|
|
4684
|
+
) THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
4685
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
4686
|
+
VALUES ('project', 'global', NEW.task_list_id, NEW.id);
|
|
4687
|
+
SELECT CASE WHEN (
|
|
4688
|
+
SELECT object_id FROM canonical_slug_claims
|
|
4689
|
+
WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
|
|
4690
|
+
) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
4691
|
+
END`);
|
|
4692
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_update
|
|
4693
|
+
BEFORE UPDATE OF task_list_id ON projects
|
|
4694
|
+
WHEN NEW.task_list_id IS NOT OLD.task_list_id
|
|
4695
|
+
BEGIN
|
|
4696
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = NEW.id;
|
|
4697
|
+
SELECT CASE WHEN EXISTS (
|
|
4698
|
+
SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
|
|
4699
|
+
) AND NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
|
|
4700
|
+
THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
4701
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
4702
|
+
SELECT 'project', 'global', NEW.task_list_id, NEW.id
|
|
4703
|
+
WHERE NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '';
|
|
4704
|
+
SELECT CASE WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '' AND (
|
|
4705
|
+
SELECT object_id FROM canonical_slug_claims
|
|
4706
|
+
WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
|
|
4707
|
+
) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
4708
|
+
END`);
|
|
4709
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS release_project_canonical_slug_delete
|
|
4710
|
+
AFTER DELETE ON projects
|
|
4711
|
+
BEGIN
|
|
4712
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = OLD.id;
|
|
4713
|
+
END`);
|
|
4714
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_insert
|
|
4715
|
+
BEFORE INSERT ON task_lists
|
|
4716
|
+
WHEN NEW.slug IS NOT NULL AND NEW.slug <> ''
|
|
4717
|
+
BEGIN
|
|
4718
|
+
SELECT CASE WHEN EXISTS (
|
|
4719
|
+
SELECT 1 FROM task_lists
|
|
4720
|
+
WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
|
|
4721
|
+
) THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
4722
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
4723
|
+
VALUES ('task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id);
|
|
4724
|
+
SELECT CASE WHEN (
|
|
4725
|
+
SELECT object_id FROM canonical_slug_claims
|
|
4726
|
+
WHERE kind = 'task_list'
|
|
4727
|
+
AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
|
|
4728
|
+
AND slug = NEW.slug
|
|
4729
|
+
) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
4730
|
+
END`);
|
|
4731
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_update
|
|
4732
|
+
BEFORE UPDATE OF slug, project_id ON task_lists
|
|
4733
|
+
WHEN NEW.slug IS NOT OLD.slug OR NEW.project_id IS NOT OLD.project_id
|
|
4734
|
+
BEGIN
|
|
4735
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = NEW.id;
|
|
4736
|
+
SELECT CASE WHEN EXISTS (
|
|
4737
|
+
SELECT 1 FROM task_lists
|
|
4738
|
+
WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
|
|
4739
|
+
) AND NEW.slug IS NOT NULL AND NEW.slug <> ''
|
|
4740
|
+
THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
4741
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
4742
|
+
SELECT 'task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id
|
|
4743
|
+
WHERE NEW.slug IS NOT NULL AND NEW.slug <> '';
|
|
4744
|
+
SELECT CASE WHEN NEW.slug IS NOT NULL AND NEW.slug <> '' AND (
|
|
4745
|
+
SELECT object_id FROM canonical_slug_claims
|
|
4746
|
+
WHERE kind = 'task_list'
|
|
4747
|
+
AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
|
|
4748
|
+
AND slug = NEW.slug
|
|
4749
|
+
) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
4750
|
+
END`);
|
|
4751
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS release_task_list_canonical_slug_delete
|
|
4752
|
+
AFTER DELETE ON task_lists
|
|
4753
|
+
BEGIN
|
|
4754
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = OLD.id;
|
|
4755
|
+
END`);
|
|
4291
4756
|
ensureTable("storage_tombstones", `
|
|
4292
4757
|
CREATE TABLE storage_tombstones (
|
|
4293
4758
|
id TEXT PRIMARY KEY,
|
|
@@ -5610,11 +6075,25 @@ var init_storage_tombstones = __esm(() => {
|
|
|
5610
6075
|
init_machines();
|
|
5611
6076
|
});
|
|
5612
6077
|
|
|
6078
|
+
// src/db/slug-claims.ts
|
|
6079
|
+
function taskListSlugScopeKey(projectId) {
|
|
6080
|
+
return projectId ? `project:${projectId}` : "standalone:";
|
|
6081
|
+
}
|
|
6082
|
+
function claimCanonicalSlug(kind, scopeKey, slug, objectId, db) {
|
|
6083
|
+
db.run(`INSERT OR IGNORE INTO canonical_slug_claims (kind, scope_key, slug, object_id)
|
|
6084
|
+
VALUES (?, ?, ?, ?)`, [kind, scopeKey, slug, objectId]);
|
|
6085
|
+
const claim = db.query("SELECT object_id FROM canonical_slug_claims WHERE kind = ? AND scope_key = ? AND slug = ?").get(kind, scopeKey, slug);
|
|
6086
|
+
return claim?.object_id === objectId;
|
|
6087
|
+
}
|
|
6088
|
+
function releaseCanonicalSlugClaims(kind, objectId, db) {
|
|
6089
|
+
db.run("DELETE FROM canonical_slug_claims WHERE kind = ? AND object_id = ?", [kind, objectId]);
|
|
6090
|
+
}
|
|
6091
|
+
|
|
5613
6092
|
// src/db/projects.ts
|
|
5614
6093
|
var exports_projects = {};
|
|
5615
6094
|
__export(exports_projects, {
|
|
5616
6095
|
updateProject: () => updateProject2,
|
|
5617
|
-
slugify: () =>
|
|
6096
|
+
slugify: () => slugify,
|
|
5618
6097
|
setMachineLocalPath: () => setMachineLocalPath,
|
|
5619
6098
|
renameProject: () => renameProject,
|
|
5620
6099
|
removeProjectSource: () => removeProjectSource,
|
|
@@ -5632,8 +6111,8 @@ __export(exports_projects, {
|
|
|
5632
6111
|
createProject: () => createProject2,
|
|
5633
6112
|
addProjectSource: () => addProjectSource
|
|
5634
6113
|
});
|
|
5635
|
-
function
|
|
5636
|
-
return name
|
|
6114
|
+
function slugify(name) {
|
|
6115
|
+
return normalizeSlug(name);
|
|
5637
6116
|
}
|
|
5638
6117
|
function generatePrefix(name, db) {
|
|
5639
6118
|
const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
|
|
@@ -5657,14 +6136,23 @@ function generatePrefix(name, db) {
|
|
|
5657
6136
|
}
|
|
5658
6137
|
function createProject2(input, db) {
|
|
5659
6138
|
const d = db || getDatabase();
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
6139
|
+
return d.transaction(() => {
|
|
6140
|
+
const id = uuid();
|
|
6141
|
+
const timestamp = now();
|
|
6142
|
+
const derivedSlug = slugify(input.name);
|
|
6143
|
+
const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
|
|
6144
|
+
if (!derivedSlug || !taskListId)
|
|
6145
|
+
throw new Error("Project name and task-list slug must be non-empty");
|
|
6146
|
+
const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
|
|
6147
|
+
if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
|
|
6148
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
|
|
6149
|
+
}
|
|
6150
|
+
const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
|
|
6151
|
+
const machineId = currentStorageMachineId(d);
|
|
6152
|
+
d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
|
|
6153
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
|
|
6154
|
+
return getProject(id, d);
|
|
6155
|
+
})();
|
|
5668
6156
|
}
|
|
5669
6157
|
function getProject(id, db) {
|
|
5670
6158
|
const d = db || getDatabase();
|
|
@@ -5692,6 +6180,9 @@ function updateProject2(id, input, db) {
|
|
|
5692
6180
|
const project = getProject(id, d);
|
|
5693
6181
|
if (!project)
|
|
5694
6182
|
throw new ProjectNotFoundError(id);
|
|
6183
|
+
if ("task_list_id" in input) {
|
|
6184
|
+
throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
|
|
6185
|
+
}
|
|
5695
6186
|
const sets = ["updated_at = ?"];
|
|
5696
6187
|
const params = [now()];
|
|
5697
6188
|
if (input.name !== undefined) {
|
|
@@ -5702,10 +6193,6 @@ function updateProject2(id, input, db) {
|
|
|
5702
6193
|
sets.push("description = ?");
|
|
5703
6194
|
params.push(input.description);
|
|
5704
6195
|
}
|
|
5705
|
-
if (input.task_list_id !== undefined) {
|
|
5706
|
-
sets.push("task_list_id = ?");
|
|
5707
|
-
params.push(input.task_list_id);
|
|
5708
|
-
}
|
|
5709
6196
|
if (input.path !== undefined) {
|
|
5710
6197
|
sets.push("path = ?");
|
|
5711
6198
|
params.push(input.path);
|
|
@@ -5716,29 +6203,41 @@ function updateProject2(id, input, db) {
|
|
|
5716
6203
|
}
|
|
5717
6204
|
function renameProject(id, input, db) {
|
|
5718
6205
|
const d = db || getDatabase();
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
6206
|
+
return d.transaction(() => {
|
|
6207
|
+
const project = getProject(id, d);
|
|
6208
|
+
if (!project)
|
|
6209
|
+
throw new ProjectNotFoundError(id);
|
|
6210
|
+
let taskListsUpdated = 0;
|
|
6211
|
+
const ts = now();
|
|
6212
|
+
if (input.new_slug !== undefined) {
|
|
6213
|
+
const normalised = normalizeSlug(input.new_slug);
|
|
6214
|
+
if (!normalised)
|
|
6215
|
+
throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
|
|
6216
|
+
const oldSlug = project.task_list_id;
|
|
6217
|
+
if (normalised !== oldSlug) {
|
|
6218
|
+
const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
|
|
6219
|
+
if (conflict) {
|
|
6220
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
|
|
6221
|
+
}
|
|
6222
|
+
const taskListConflict = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND slug != COALESCE(?, '') LIMIT 1").get(id, normalised, oldSlug);
|
|
6223
|
+
if (taskListConflict) {
|
|
6224
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalised}" is already used in project "${project.name}"`);
|
|
6225
|
+
}
|
|
6226
|
+
releaseCanonicalSlugClaims("project", id, d);
|
|
6227
|
+
if (!claimCanonicalSlug("project", "global", normalised, id, d)) {
|
|
6228
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
|
|
6229
|
+
}
|
|
6230
|
+
d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
|
|
6231
|
+
}
|
|
6232
|
+
if (oldSlug && (normalised !== oldSlug || input.name !== undefined && input.name !== project.name)) {
|
|
6233
|
+
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;
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6236
|
+
if (input.name !== undefined && input.name !== project.name) {
|
|
6237
|
+
d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
|
|
6238
|
+
}
|
|
6239
|
+
return { project: getProject(id, d), task_lists_updated: taskListsUpdated };
|
|
6240
|
+
})();
|
|
5742
6241
|
}
|
|
5743
6242
|
function deleteProject(id, db) {
|
|
5744
6243
|
const d = db || getDatabase();
|
|
@@ -5750,8 +6249,10 @@ function deleteProject(id, db) {
|
|
|
5750
6249
|
object_id: id,
|
|
5751
6250
|
payload: project
|
|
5752
6251
|
}, d);
|
|
5753
|
-
|
|
5754
|
-
|
|
6252
|
+
return d.transaction(() => {
|
|
6253
|
+
releaseCanonicalSlugClaims("project", id, d);
|
|
6254
|
+
return d.run("DELETE FROM projects WHERE id = ?", [id]).changes > 0;
|
|
6255
|
+
})();
|
|
5755
6256
|
}
|
|
5756
6257
|
function rowToSource(row) {
|
|
5757
6258
|
return {
|
|
@@ -7205,19 +7706,22 @@ function rowToTaskList(row) {
|
|
|
7205
7706
|
}
|
|
7206
7707
|
function createTaskList2(input, db) {
|
|
7207
7708
|
const d = db || getDatabase();
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
|
|
7709
|
+
return d.transaction(() => {
|
|
7710
|
+
const id = uuid();
|
|
7711
|
+
const timestamp = now();
|
|
7712
|
+
const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
|
|
7713
|
+
if (!slug)
|
|
7714
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
7715
|
+
const machineId = currentStorageMachineId(d);
|
|
7716
|
+
const scopeKey = taskListSlugScopeKey(input.project_id);
|
|
7717
|
+
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);
|
|
7718
|
+
if (existing || !claimCanonicalSlug("task_list", scopeKey, slug, id, d)) {
|
|
7719
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
7720
|
+
}
|
|
7721
|
+
d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
|
|
7722
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
|
|
7723
|
+
return getTaskList(id, d);
|
|
7724
|
+
})();
|
|
7221
7725
|
}
|
|
7222
7726
|
function getTaskList(id, db) {
|
|
7223
7727
|
const d = db || getDatabase();
|
|
@@ -7243,26 +7747,45 @@ function listTaskLists(projectId, db) {
|
|
|
7243
7747
|
}
|
|
7244
7748
|
function updateTaskList2(id, input, db) {
|
|
7245
7749
|
const d = db || getDatabase();
|
|
7246
|
-
|
|
7247
|
-
|
|
7248
|
-
|
|
7249
|
-
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7750
|
+
return d.transaction(() => {
|
|
7751
|
+
const existing = getTaskList(id, d);
|
|
7752
|
+
if (!existing)
|
|
7753
|
+
throw new TaskListNotFoundError(id);
|
|
7754
|
+
const sets = ["updated_at = ?"];
|
|
7755
|
+
const params = [now()];
|
|
7756
|
+
if (input.slug !== undefined) {
|
|
7757
|
+
const slug = slugify(input.slug);
|
|
7758
|
+
if (!slug)
|
|
7759
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
7760
|
+
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);
|
|
7761
|
+
if (duplicate) {
|
|
7762
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
7763
|
+
}
|
|
7764
|
+
if (slug !== existing.slug) {
|
|
7765
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
7766
|
+
if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(existing.project_id), slug, id, d)) {
|
|
7767
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
7768
|
+
}
|
|
7769
|
+
}
|
|
7770
|
+
sets.push("slug = ?");
|
|
7771
|
+
params.push(slug);
|
|
7772
|
+
}
|
|
7773
|
+
if (input.name !== undefined) {
|
|
7774
|
+
sets.push("name = ?");
|
|
7775
|
+
params.push(input.name);
|
|
7776
|
+
}
|
|
7777
|
+
if (input.description !== undefined) {
|
|
7778
|
+
sets.push("description = ?");
|
|
7779
|
+
params.push(input.description);
|
|
7780
|
+
}
|
|
7781
|
+
if (input.metadata !== undefined) {
|
|
7782
|
+
sets.push("metadata = ?");
|
|
7783
|
+
params.push(JSON.stringify(input.metadata));
|
|
7784
|
+
}
|
|
7785
|
+
params.push(id);
|
|
7786
|
+
d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
|
|
7787
|
+
return getTaskList(id, d);
|
|
7788
|
+
})();
|
|
7266
7789
|
}
|
|
7267
7790
|
function deleteTaskList(id, db) {
|
|
7268
7791
|
const d = db || getDatabase();
|
|
@@ -7274,7 +7797,10 @@ function deleteTaskList(id, db) {
|
|
|
7274
7797
|
object_id: id,
|
|
7275
7798
|
payload: list
|
|
7276
7799
|
}, d);
|
|
7277
|
-
return d.
|
|
7800
|
+
return d.transaction(() => {
|
|
7801
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
7802
|
+
return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
7803
|
+
})();
|
|
7278
7804
|
}
|
|
7279
7805
|
function ensureTaskList(name, slug, projectId, db) {
|
|
7280
7806
|
const d = db || getDatabase();
|
|
@@ -10605,10 +11131,10 @@ var init_task_relations = __esm(() => {
|
|
|
10605
11131
|
|
|
10606
11132
|
// src/db/plans.ts
|
|
10607
11133
|
function planSlugBase3(value) {
|
|
10608
|
-
return
|
|
11134
|
+
return slugify(value) || "plan";
|
|
10609
11135
|
}
|
|
10610
11136
|
function normalizePlanSlug2(value) {
|
|
10611
|
-
const slug =
|
|
11137
|
+
const slug = slugify(value);
|
|
10612
11138
|
if (!slug)
|
|
10613
11139
|
throw new Error("Invalid plan slug");
|
|
10614
11140
|
return slug;
|
|
@@ -14789,6 +15315,14 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
14789
15315
|
skipped: 0,
|
|
14790
15316
|
errors: []
|
|
14791
15317
|
};
|
|
15318
|
+
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
15319
|
+
if (result.errors.length === 0) {
|
|
15320
|
+
const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
|
|
15321
|
+
const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
|
|
15322
|
+
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
15323
|
+
}
|
|
15324
|
+
if (result.errors.length > 0)
|
|
15325
|
+
return result;
|
|
14792
15326
|
const applyRows = (objectType, table, columns, rows, updateClockColumn, afterUpsert) => {
|
|
14793
15327
|
for (const row of rows) {
|
|
14794
15328
|
try {
|
|
@@ -15161,6 +15695,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
15161
15695
|
getByPath: (path) => getProjectByPath(path, database()),
|
|
15162
15696
|
list: () => listProjects(database()),
|
|
15163
15697
|
update: (id, input) => updateProject2(id, input, database()),
|
|
15698
|
+
rename: (id, input) => renameProject(id, input, database()),
|
|
15164
15699
|
delete: (id) => deleteProject(id, database())
|
|
15165
15700
|
},
|
|
15166
15701
|
plans: {
|
|
@@ -15484,6 +16019,7 @@ function emptySnapshot() {
|
|
|
15484
16019
|
var MAX_BACKOFF_MS;
|
|
15485
16020
|
var init_shadow_outbox = __esm(() => {
|
|
15486
16021
|
init_local_sqlite();
|
|
16022
|
+
init_postgres_sync();
|
|
15487
16023
|
init_shadow_outbox_schema();
|
|
15488
16024
|
init_shadow_outbox_schema();
|
|
15489
16025
|
MAX_BACKOFF_MS = 5 * 60000;
|
|
@@ -15598,6 +16134,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
15598
16134
|
schemas: {
|
|
15599
16135
|
Task: taskSchema,
|
|
15600
16136
|
Project: projectSchema,
|
|
16137
|
+
TaskList: taskListSchema,
|
|
15601
16138
|
TaskComment: taskCommentSchema,
|
|
15602
16139
|
CreateTaskInput: {
|
|
15603
16140
|
type: "object",
|
|
@@ -15626,12 +16163,65 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
15626
16163
|
},
|
|
15627
16164
|
CreateProjectInput: {
|
|
15628
16165
|
type: "object",
|
|
16166
|
+
additionalProperties: false,
|
|
15629
16167
|
required: ["name", "path"],
|
|
15630
16168
|
properties: {
|
|
16169
|
+
name: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
|
|
16170
|
+
path: { type: "string", minLength: 1 },
|
|
16171
|
+
description: { type: "string" },
|
|
16172
|
+
task_list_id: { type: "string", minLength: 1, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
|
|
16173
|
+
task_prefix: { type: "string", minLength: 1 }
|
|
16174
|
+
}
|
|
16175
|
+
},
|
|
16176
|
+
UpdateProjectInput: {
|
|
16177
|
+
type: "object",
|
|
16178
|
+
additionalProperties: false,
|
|
16179
|
+
minProperties: 1,
|
|
16180
|
+
properties: {
|
|
16181
|
+
name: { type: "string", minLength: 1 },
|
|
16182
|
+
path: { type: "string", minLength: 1 },
|
|
16183
|
+
description: { type: "string", nullable: true }
|
|
16184
|
+
}
|
|
16185
|
+
},
|
|
16186
|
+
RenameProjectInput: {
|
|
16187
|
+
type: "object",
|
|
16188
|
+
additionalProperties: false,
|
|
16189
|
+
required: ["new_slug"],
|
|
16190
|
+
properties: {
|
|
16191
|
+
new_slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
|
|
16192
|
+
name: { type: "string", minLength: 1 }
|
|
16193
|
+
}
|
|
16194
|
+
},
|
|
16195
|
+
ErrorResponse: {
|
|
16196
|
+
type: "object",
|
|
16197
|
+
required: ["error"],
|
|
16198
|
+
properties: {
|
|
16199
|
+
error: { type: "string" },
|
|
16200
|
+
code: { type: "string" },
|
|
16201
|
+
conflict: { type: "boolean" }
|
|
16202
|
+
}
|
|
16203
|
+
},
|
|
16204
|
+
CreateTaskListInput: {
|
|
16205
|
+
type: "object",
|
|
16206
|
+
additionalProperties: false,
|
|
16207
|
+
required: ["name"],
|
|
16208
|
+
properties: {
|
|
16209
|
+
name: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
|
|
16210
|
+
slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
|
|
16211
|
+
project_id: { type: "string" },
|
|
16212
|
+
description: { type: "string" },
|
|
16213
|
+
metadata: { type: "object", additionalProperties: true }
|
|
16214
|
+
}
|
|
16215
|
+
},
|
|
16216
|
+
UpdateTaskListInput: {
|
|
16217
|
+
type: "object",
|
|
16218
|
+
additionalProperties: false,
|
|
16219
|
+
minProperties: 1,
|
|
16220
|
+
properties: {
|
|
16221
|
+
slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
|
|
15631
16222
|
name: { type: "string" },
|
|
15632
|
-
path: { type: "string" },
|
|
15633
16223
|
description: { type: "string" },
|
|
15634
|
-
|
|
16224
|
+
metadata: { type: "object", additionalProperties: true }
|
|
15635
16225
|
}
|
|
15636
16226
|
},
|
|
15637
16227
|
CreateTaskCommentInput: {
|
|
@@ -15840,7 +16430,10 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
15840
16430
|
required: true,
|
|
15841
16431
|
content: { "application/json": { schema: { $ref: "#/components/schemas/CreateProjectInput" } } }
|
|
15842
16432
|
},
|
|
15843
|
-
responses: {
|
|
16433
|
+
responses: {
|
|
16434
|
+
"201": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } },
|
|
16435
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
16436
|
+
}
|
|
15844
16437
|
}
|
|
15845
16438
|
},
|
|
15846
16439
|
"/v1/projects/{id}": {
|
|
@@ -15849,6 +16442,86 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
15849
16442
|
summary: "Get a project by id",
|
|
15850
16443
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
15851
16444
|
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
|
|
16445
|
+
},
|
|
16446
|
+
patch: {
|
|
16447
|
+
operationId: "updateProject",
|
|
16448
|
+
summary: "Update a project",
|
|
16449
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
16450
|
+
requestBody: {
|
|
16451
|
+
required: true,
|
|
16452
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/UpdateProjectInput" } } }
|
|
16453
|
+
},
|
|
16454
|
+
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
|
|
16455
|
+
},
|
|
16456
|
+
delete: {
|
|
16457
|
+
operationId: "deleteProject",
|
|
16458
|
+
summary: "Delete a project",
|
|
16459
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
16460
|
+
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
|
|
16461
|
+
}
|
|
16462
|
+
},
|
|
16463
|
+
"/v1/projects/{id}/rename": {
|
|
16464
|
+
post: {
|
|
16465
|
+
operationId: "renameProject",
|
|
16466
|
+
summary: "Atomically rename a project and its canonical task list",
|
|
16467
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
16468
|
+
requestBody: {
|
|
16469
|
+
required: true,
|
|
16470
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/RenameProjectInput" } } }
|
|
16471
|
+
},
|
|
16472
|
+
responses: {
|
|
16473
|
+
"200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" }, task_lists_updated: { type: "number" } } } } } },
|
|
16474
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
16475
|
+
}
|
|
16476
|
+
}
|
|
16477
|
+
},
|
|
16478
|
+
"/v1/task-lists": {
|
|
16479
|
+
get: {
|
|
16480
|
+
operationId: "listTaskLists",
|
|
16481
|
+
summary: "List task lists",
|
|
16482
|
+
parameters: [{ name: "project_id", in: "query", schema: { type: "string" } }],
|
|
16483
|
+
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { task_lists: { type: "array", items: { $ref: "#/components/schemas/TaskList" } }, count: { type: "number" } } } } } } }
|
|
16484
|
+
},
|
|
16485
|
+
post: {
|
|
16486
|
+
operationId: "createTaskList",
|
|
16487
|
+
summary: "Create a task list",
|
|
16488
|
+
requestBody: {
|
|
16489
|
+
required: true,
|
|
16490
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/CreateTaskListInput" } } }
|
|
16491
|
+
},
|
|
16492
|
+
responses: {
|
|
16493
|
+
"201": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } },
|
|
16494
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
16495
|
+
}
|
|
16496
|
+
}
|
|
16497
|
+
},
|
|
16498
|
+
"/v1/task-lists/{id}": {
|
|
16499
|
+
get: {
|
|
16500
|
+
operationId: "getTaskList",
|
|
16501
|
+
summary: "Get a task list by id",
|
|
16502
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
16503
|
+
responses: {
|
|
16504
|
+
"200": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } }
|
|
16505
|
+
}
|
|
16506
|
+
},
|
|
16507
|
+
patch: {
|
|
16508
|
+
operationId: "updateTaskList",
|
|
16509
|
+
summary: "Update a task list",
|
|
16510
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
16511
|
+
requestBody: {
|
|
16512
|
+
required: true,
|
|
16513
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/UpdateTaskListInput" } } }
|
|
16514
|
+
},
|
|
16515
|
+
responses: {
|
|
16516
|
+
"200": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } },
|
|
16517
|
+
"409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
|
|
16518
|
+
}
|
|
16519
|
+
},
|
|
16520
|
+
delete: {
|
|
16521
|
+
operationId: "deleteTaskList",
|
|
16522
|
+
summary: "Delete a task list",
|
|
16523
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
16524
|
+
responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
|
|
15852
16525
|
}
|
|
15853
16526
|
},
|
|
15854
16527
|
"/v1/stats": {
|
|
@@ -15915,7 +16588,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
|
|
|
15915
16588
|
}
|
|
15916
16589
|
};
|
|
15917
16590
|
}
|
|
15918
|
-
var taskSchema, projectSchema, taskCommentSchema;
|
|
16591
|
+
var taskSchema, projectSchema, taskListSchema, taskCommentSchema;
|
|
15919
16592
|
var init_openapi = __esm(() => {
|
|
15920
16593
|
init_package_version();
|
|
15921
16594
|
taskSchema = {
|
|
@@ -15942,6 +16615,22 @@ var init_openapi = __esm(() => {
|
|
|
15942
16615
|
name: { type: "string" },
|
|
15943
16616
|
path: { type: "string" },
|
|
15944
16617
|
description: { type: "string", nullable: true },
|
|
16618
|
+
task_list_id: { type: "string", nullable: true },
|
|
16619
|
+
task_prefix: { type: "string", nullable: true },
|
|
16620
|
+
task_counter: { type: "number" },
|
|
16621
|
+
created_at: { type: "string" },
|
|
16622
|
+
updated_at: { type: "string" }
|
|
16623
|
+
}
|
|
16624
|
+
};
|
|
16625
|
+
taskListSchema = {
|
|
16626
|
+
type: "object",
|
|
16627
|
+
properties: {
|
|
16628
|
+
id: { type: "string" },
|
|
16629
|
+
project_id: { type: "string", nullable: true },
|
|
16630
|
+
slug: { type: "string" },
|
|
16631
|
+
name: { type: "string" },
|
|
16632
|
+
description: { type: "string", nullable: true },
|
|
16633
|
+
metadata: { type: "object", additionalProperties: true },
|
|
15945
16634
|
created_at: { type: "string" },
|
|
15946
16635
|
updated_at: { type: "string" }
|
|
15947
16636
|
}
|
|
@@ -15975,6 +16664,48 @@ function json2(body, status = 200) {
|
|
|
15975
16664
|
function error(status, message, extra) {
|
|
15976
16665
|
return json2({ error: message, ...extra ?? {} }, status);
|
|
15977
16666
|
}
|
|
16667
|
+
function validateProjectPatch(value) {
|
|
16668
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
16669
|
+
return { ok: false, message: "project patch must be an object" };
|
|
16670
|
+
const body = value;
|
|
16671
|
+
const allowed = new Set(["name", "path", "description"]);
|
|
16672
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
16673
|
+
if (unknown)
|
|
16674
|
+
return { ok: false, message: `unknown project field: ${unknown}` };
|
|
16675
|
+
if (Object.keys(body).length === 0)
|
|
16676
|
+
return { ok: false, message: "project patch must not be empty" };
|
|
16677
|
+
if (body["name"] !== undefined && (typeof body["name"] !== "string" || !body["name"].trim()))
|
|
16678
|
+
return { ok: false, message: "name must be a non-empty string" };
|
|
16679
|
+
if (body["path"] !== undefined && (typeof body["path"] !== "string" || !body["path"].trim()))
|
|
16680
|
+
return { ok: false, message: "path must be a non-empty string" };
|
|
16681
|
+
if (body["description"] !== undefined && body["description"] !== null && typeof body["description"] !== "string")
|
|
16682
|
+
return { ok: false, message: "description must be a string or null" };
|
|
16683
|
+
return { ok: true, patch: body };
|
|
16684
|
+
}
|
|
16685
|
+
function validateProjectCreate(value) {
|
|
16686
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
16687
|
+
return { ok: false, message: "project body must be an object" };
|
|
16688
|
+
const body = value;
|
|
16689
|
+
const allowed = new Set(["name", "path", "description", "task_list_id", "task_prefix"]);
|
|
16690
|
+
const unknown = Object.keys(body).find((key) => !allowed.has(key));
|
|
16691
|
+
if (unknown)
|
|
16692
|
+
return { ok: false, message: `unknown project field: ${unknown}` };
|
|
16693
|
+
if (typeof body["name"] !== "string" || !body["name"].trim())
|
|
16694
|
+
return { ok: false, message: "name must be a non-empty string" };
|
|
16695
|
+
if (!normalizeSlug(body["name"]))
|
|
16696
|
+
return { ok: false, message: "name must produce a non-empty canonical slug" };
|
|
16697
|
+
if (typeof body["path"] !== "string" || !body["path"].trim())
|
|
16698
|
+
return { ok: false, message: "path must be a non-empty string" };
|
|
16699
|
+
if (body["description"] !== undefined && typeof body["description"] !== "string")
|
|
16700
|
+
return { ok: false, message: "description must be a string" };
|
|
16701
|
+
if (body["task_list_id"] !== undefined && !isCanonicalSlug(body["task_list_id"])) {
|
|
16702
|
+
return { ok: false, message: "task_list_id must be non-empty canonical kebab-case" };
|
|
16703
|
+
}
|
|
16704
|
+
if (body["task_prefix"] !== undefined && (typeof body["task_prefix"] !== "string" || !body["task_prefix"].trim())) {
|
|
16705
|
+
return { ok: false, message: "task_prefix must be a non-empty string" };
|
|
16706
|
+
}
|
|
16707
|
+
return { ok: true, input: body };
|
|
16708
|
+
}
|
|
15978
16709
|
async function readJson(req) {
|
|
15979
16710
|
try {
|
|
15980
16711
|
const text = await req.text();
|
|
@@ -16455,14 +17186,31 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
16455
17186
|
}
|
|
16456
17187
|
if (method === "POST") {
|
|
16457
17188
|
const body = await readJson(req);
|
|
16458
|
-
if (!body
|
|
16459
|
-
return error(400, "
|
|
16460
|
-
|
|
16461
|
-
|
|
17189
|
+
if (!body)
|
|
17190
|
+
return error(400, "invalid JSON body");
|
|
17191
|
+
const validated = validateProjectCreate(body);
|
|
17192
|
+
if (!validated.ok)
|
|
17193
|
+
return error(400, validated.message);
|
|
17194
|
+
const project = await store.projects.create(validated.input, contextFromPrincipal(principal));
|
|
16462
17195
|
return json2({ project }, 201);
|
|
16463
17196
|
}
|
|
16464
17197
|
return error(405, `method ${method} not allowed on /v1/projects`);
|
|
16465
17198
|
}
|
|
17199
|
+
if (action === "rename") {
|
|
17200
|
+
if (method !== "POST")
|
|
17201
|
+
return error(405, `method ${method} not allowed on /v1/projects/:id/rename`);
|
|
17202
|
+
const body = await readJson(req);
|
|
17203
|
+
if (!body || typeof body.new_slug !== "string" || !body.new_slug.trim() || !normalizeSlug(body.new_slug)) {
|
|
17204
|
+
return error(400, "new_slug must be a non-empty string");
|
|
17205
|
+
}
|
|
17206
|
+
if (body.name !== undefined && (typeof body.name !== "string" || !body.name.trim())) {
|
|
17207
|
+
return error(400, "name must be a non-empty string");
|
|
17208
|
+
}
|
|
17209
|
+
const unknownField = Object.keys(body).find((key) => !["new_slug", "name"].includes(key));
|
|
17210
|
+
if (unknownField)
|
|
17211
|
+
return error(400, `unknown project rename field: ${unknownField}`);
|
|
17212
|
+
return json2(await store.projects.rename(id, body, contextFromPrincipal(principal)));
|
|
17213
|
+
}
|
|
16466
17214
|
if (method === "GET") {
|
|
16467
17215
|
const project = await store.projects.get(id);
|
|
16468
17216
|
return project ? json2({ project }) : error(404, "project not found");
|
|
@@ -16471,8 +17219,13 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
16471
17219
|
const body = await readJson(req);
|
|
16472
17220
|
if (!body)
|
|
16473
17221
|
return error(400, "invalid JSON body");
|
|
16474
|
-
const
|
|
16475
|
-
|
|
17222
|
+
const validated = validateProjectPatch(body);
|
|
17223
|
+
if (!validated.ok)
|
|
17224
|
+
return error(400, validated.message);
|
|
17225
|
+
if (!await store.projects.get(id))
|
|
17226
|
+
return error(404, "project not found");
|
|
17227
|
+
const project = await store.projects.update(id, validated.patch);
|
|
17228
|
+
return json2({ project });
|
|
16476
17229
|
}
|
|
16477
17230
|
if (method === "DELETE") {
|
|
16478
17231
|
await store.projects.delete(id, contextFromPrincipal(principal));
|
|
@@ -16559,6 +17312,21 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
16559
17312
|
const body = await readJson(req);
|
|
16560
17313
|
if (!body || typeof body.name !== "string" || !body.name.trim())
|
|
16561
17314
|
return error(400, "name is required");
|
|
17315
|
+
const unknownField = Object.keys(body).find((key) => !["name", "slug", "project_id", "description", "metadata"].includes(key));
|
|
17316
|
+
if (unknownField)
|
|
17317
|
+
return error(400, `unsupported task-list create field: ${unknownField}`);
|
|
17318
|
+
if (body.slug !== undefined && typeof body.slug !== "string")
|
|
17319
|
+
return error(400, "slug must be a string");
|
|
17320
|
+
if (body.project_id !== undefined && (typeof body.project_id !== "string" || !body.project_id.trim()))
|
|
17321
|
+
return error(400, "project_id must be a non-empty string");
|
|
17322
|
+
if (body.description !== undefined && typeof body.description !== "string")
|
|
17323
|
+
return error(400, "description must be a string");
|
|
17324
|
+
if (body.metadata !== undefined && (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
|
|
17325
|
+
return error(400, "metadata must be an object");
|
|
17326
|
+
}
|
|
17327
|
+
if (!normalizeSlug(body.slug === undefined ? body.name : body.slug)) {
|
|
17328
|
+
return error(400, "task-list slug must be non-empty kebab-case");
|
|
17329
|
+
}
|
|
16562
17330
|
const taskList = await store.taskLists.create(body, contextFromPrincipal(principal));
|
|
16563
17331
|
return json2({ task_list: taskList }, 201);
|
|
16564
17332
|
}
|
|
@@ -16566,6 +17334,29 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
16566
17334
|
const taskList = await store.taskLists.get(id);
|
|
16567
17335
|
return taskList ? json2({ task_list: taskList }) : error(404, "task list not found");
|
|
16568
17336
|
}
|
|
17337
|
+
if (id && (method === "PATCH" || method === "PUT")) {
|
|
17338
|
+
const body = await readJson(req);
|
|
17339
|
+
if (!body)
|
|
17340
|
+
return error(400, "invalid JSON body");
|
|
17341
|
+
const unknownField = Object.keys(body).find((key) => !["slug", "name", "description", "metadata"].includes(key));
|
|
17342
|
+
if (unknownField)
|
|
17343
|
+
return error(400, `unsupported task-list update field: ${unknownField}`);
|
|
17344
|
+
if (Object.keys(body).length === 0)
|
|
17345
|
+
return error(400, "task-list update must not be empty");
|
|
17346
|
+
if (body.slug !== undefined && (typeof body.slug !== "string" || !normalizeSlug(body.slug)))
|
|
17347
|
+
return error(400, "slug must be a non-empty string");
|
|
17348
|
+
if (body.name !== undefined && (typeof body.name !== "string" || !body.name.trim()))
|
|
17349
|
+
return error(400, "name must be a non-empty string");
|
|
17350
|
+
if (body.description !== undefined && typeof body.description !== "string")
|
|
17351
|
+
return error(400, "description must be a string");
|
|
17352
|
+
if (body.metadata !== undefined && (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
|
|
17353
|
+
return error(400, "metadata must be an object");
|
|
17354
|
+
}
|
|
17355
|
+
if (!await store.taskLists.get(id))
|
|
17356
|
+
return error(404, "task list not found");
|
|
17357
|
+
const taskList = await store.taskLists.update(id, body);
|
|
17358
|
+
return json2({ task_list: taskList });
|
|
17359
|
+
}
|
|
16569
17360
|
if (id && method === "DELETE") {
|
|
16570
17361
|
const deleted = await store.taskLists.delete(id, contextFromPrincipal(principal));
|
|
16571
17362
|
return deleted ? json2({ deleted: true, id }) : error(404, "task list not found");
|
|
@@ -16638,6 +17429,10 @@ async function handleV1Request(req, url, dependencies = {}) {
|
|
|
16638
17429
|
} catch (e) {
|
|
16639
17430
|
if (e instanceof LockError)
|
|
16640
17431
|
return error(409, e.message, { code: LockError.code });
|
|
17432
|
+
if (e instanceof ResourceConflictError)
|
|
17433
|
+
return error(409, e.message, { code: e.code, conflict: true });
|
|
17434
|
+
if (e instanceof ProjectNotFoundError)
|
|
17435
|
+
return error(404, e.message, { code: ProjectNotFoundError.code });
|
|
16641
17436
|
return error(500, e.message || "internal error");
|
|
16642
17437
|
}
|
|
16643
17438
|
}
|
|
@@ -44933,7 +45728,7 @@ function addSourceOnce(projectId, type, name, uri, metadata, db) {
|
|
|
44933
45728
|
function bootstrapProject(options = {}, db) {
|
|
44934
45729
|
const d = db || getDatabase();
|
|
44935
45730
|
const discovery = discoverProjectWorkspace(options.path);
|
|
44936
|
-
const taskListSlug = options.taskListSlug || `todos-${
|
|
45731
|
+
const taskListSlug = options.taskListSlug || `todos-${slugify(options.name || discovery.projectName)}`;
|
|
44937
45732
|
if (options.dryRun) {
|
|
44938
45733
|
return {
|
|
44939
45734
|
dryRun: true,
|
|
@@ -44948,10 +45743,10 @@ function bootstrapProject(options = {}, db) {
|
|
|
44948
45743
|
let project = ensureProject(options.name || discovery.projectName, discovery.projectPath, d);
|
|
44949
45744
|
const createdProject = !beforeProject;
|
|
44950
45745
|
if (project.task_list_id !== taskListSlug || options.name && project.name !== options.name) {
|
|
44951
|
-
project =
|
|
45746
|
+
project = renameProject(project.id, {
|
|
44952
45747
|
name: options.name ?? project.name,
|
|
44953
|
-
|
|
44954
|
-
}, d);
|
|
45748
|
+
new_slug: taskListSlug
|
|
45749
|
+
}, d).project;
|
|
44955
45750
|
}
|
|
44956
45751
|
setMachineLocalPath(project.id, discovery.projectPath, d);
|
|
44957
45752
|
const beforeTaskList = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ?").get(project.id, taskListSlug);
|
|
@@ -91555,6 +92350,7 @@ async function runMigrate() {
|
|
|
91555
92350
|
const {
|
|
91556
92351
|
ensureCloudSchema: ensureCloudSchema2,
|
|
91557
92352
|
ensureCloudCommentCursorIndex: ensureCloudCommentCursorIndex2,
|
|
92353
|
+
ensureCloudScopedSlugUniqueIndexes: ensureCloudScopedSlugUniqueIndexes2,
|
|
91558
92354
|
normalizeCloudPayloads: normalizeCloudPayloads2,
|
|
91559
92355
|
pingCloud: pingCloud2,
|
|
91560
92356
|
resolveCloudDatabaseUrl: resolveCloudDatabaseUrl2,
|
|
@@ -91573,6 +92369,8 @@ async function runMigrate() {
|
|
|
91573
92369
|
console.log(`migrate: normalized ${normalized} payload row(s)`);
|
|
91574
92370
|
console.log("migrate: prebuilding comment cursor index concurrently\u2026");
|
|
91575
92371
|
await ensureCloudCommentCursorIndex2();
|
|
92372
|
+
console.log("migrate: auditing scoped slug duplicates and building unique indexes concurrently\u2026");
|
|
92373
|
+
await ensureCloudScopedSlugUniqueIndexes2();
|
|
91576
92374
|
console.log("migrate: done");
|
|
91577
92375
|
await closeCloud2();
|
|
91578
92376
|
process.exit(0);
|