diff --git a/drizzle/0007_label_pds_records.sql b/drizzle/0007_label_pds_records.sql
new file mode 100644
index 0000000..63bdec7
--- /dev/null
+++ b/drizzle/0007_label_pds_records.sql
@@ -0,0 +1,15 @@
+CREATE TABLE `label_pds_records` (
+ `entity_id` text NOT NULL,
+ `entity_type` text NOT NULL,
+ `actor_did` text NOT NULL,
+ `rkey` text NOT NULL,
+ PRIMARY KEY(`entity_id`, `entity_type`, `actor_did`)
+);
+--> statement-breakpoint
+ALTER TABLE `feature_requests` RENAME COLUMN `label_tid` TO `label_updated_at`;
+--> statement-breakpoint
+ALTER TABLE `kanban_tasks` RENAME COLUMN `label_tid` TO `label_updated_at`;
+--> statement-breakpoint
+UPDATE `feature_requests` SET `label_updated_at` = NULL WHERE `label_updated_at` IS NOT NULL;
+--> statement-breakpoint
+UPDATE `kanban_tasks` SET `label_updated_at` = NULL WHERE `label_updated_at` IS NOT NULL;
diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json
index 0a24e7b..461f965 100644
--- a/drizzle/meta/0006_snapshot.json
+++ b/drizzle/meta/0006_snapshot.json
@@ -134,12 +134,8 @@
"name": "sphere_entry_counter_sphere_id_spheres_id_fk",
"tableFrom": "sphere_entry_counter",
"tableTo": "spheres",
- "columnsFrom": [
- "sphere_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["sphere_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -176,10 +172,7 @@
"indexes": {
"idx_entity_labels_entity": {
"name": "idx_entity_labels_entity",
- "columns": [
- "entity_id",
- "entity_type"
- ],
+ "columns": ["entity_id", "entity_type"],
"isUnique": false
}
},
@@ -188,22 +181,15 @@
"name": "entity_labels_label_id_sphere_labels_id_fk",
"tableFrom": "entity_labels",
"tableTo": "sphere_labels",
- "columnsFrom": [
- "label_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["label_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"entity_labels_label_id_entity_id_pk": {
- "columns": [
- "label_id",
- "entity_id"
- ],
+ "columns": ["label_id", "entity_id"],
"name": "entity_labels_label_id_entity_id_pk"
}
},
@@ -268,18 +254,12 @@
"indexes": {
"idx_sphere_labels_sphere_name": {
"name": "idx_sphere_labels_sphere_name",
- "columns": [
- "sphere_id",
- "name"
- ],
+ "columns": ["sphere_id", "name"],
"isUnique": true
},
"idx_sphere_labels_sphere_position": {
"name": "idx_sphere_labels_sphere_position",
- "columns": [
- "sphere_id",
- "position"
- ],
+ "columns": ["sphere_id", "position"],
"isUnique": false
}
},
@@ -288,12 +268,8 @@
"name": "sphere_labels_sphere_id_spheres_id_fk",
"tableFrom": "sphere_labels",
"tableTo": "spheres",
- "columnsFrom": [
- "sphere_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["sphere_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -368,9 +344,7 @@
"indexes": {
"idx_sphere_members_did": {
"name": "idx_sphere_members_did",
- "columns": [
- "did"
- ],
+ "columns": ["did"],
"isUnique": false
}
},
@@ -379,22 +353,15 @@
"name": "sphere_members_sphere_id_spheres_id_fk",
"tableFrom": "sphere_members",
"tableTo": "spheres",
- "columnsFrom": [
- "sphere_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["sphere_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"sphere_members_sphere_id_did_pk": {
- "columns": [
- "sphere_id",
- "did"
- ],
+ "columns": ["sphere_id", "did"],
"name": "sphere_members_sphere_id_did_pk"
}
},
@@ -433,22 +400,15 @@
"name": "sphere_modules_sphere_id_spheres_id_fk",
"tableFrom": "sphere_modules",
"tableTo": "spheres",
- "columnsFrom": [
- "sphere_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["sphere_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"sphere_modules_sphere_id_module_name_pk": {
- "columns": [
- "sphere_id",
- "module_name"
- ],
+ "columns": ["sphere_id", "module_name"],
"name": "sphere_modules_sphere_id_module_name_pk"
}
},
@@ -494,22 +454,15 @@
"name": "sphere_permissions_sphere_id_spheres_id_fk",
"tableFrom": "sphere_permissions",
"tableTo": "spheres",
- "columnsFrom": [
- "sphere_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["sphere_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"sphere_permissions_sphere_id_action_key_pk": {
- "columns": [
- "sphere_id",
- "action_key"
- ],
+ "columns": ["sphere_id", "action_key"],
"name": "sphere_permissions_sphere_id_action_key_pk"
}
},
@@ -589,9 +542,7 @@
"indexes": {
"spheres_handle_unique": {
"name": "spheres_handle_unique",
- "columns": [
- "handle"
- ],
+ "columns": ["handle"],
"isUnique": true
}
},
@@ -636,9 +587,7 @@
"indexes": {
"idx_feature_request_comment_votes_comment": {
"name": "idx_feature_request_comment_votes_comment",
- "columns": [
- "comment_id"
- ],
+ "columns": ["comment_id"],
"isUnique": false
}
},
@@ -647,22 +596,15 @@
"name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk",
"tableFrom": "feature_request_comment_votes",
"tableTo": "feature_request_comments",
- "columnsFrom": [
- "comment_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["comment_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"feature_request_comment_votes_comment_id_author_did_pk": {
- "columns": [
- "comment_id",
- "author_did"
- ],
+ "columns": ["comment_id", "author_did"],
"name": "feature_request_comment_votes_comment_id_author_did_pk"
}
},
@@ -733,17 +675,12 @@
"indexes": {
"idx_feature_request_comments_request": {
"name": "idx_feature_request_comments_request",
- "columns": [
- "request_id"
- ],
+ "columns": ["request_id"],
"isUnique": false
},
"idx_feature_request_comments_author_request": {
"name": "idx_feature_request_comments_author_request",
- "columns": [
- "author_did",
- "request_id"
- ],
+ "columns": ["author_did", "request_id"],
"isUnique": false
}
},
@@ -752,12 +689,8 @@
"name": "feature_request_comments_request_id_feature_requests_id_fk",
"tableFrom": "feature_request_comments",
"tableTo": "feature_requests",
- "columnsFrom": [
- "request_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["request_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -808,9 +741,7 @@
"indexes": {
"idx_feature_request_statuses_request": {
"name": "idx_feature_request_statuses_request",
- "columns": [
- "request_id"
- ],
+ "columns": ["request_id"],
"isUnique": false
}
},
@@ -819,12 +750,8 @@
"name": "feature_request_statuses_request_id_feature_requests_id_fk",
"tableFrom": "feature_request_statuses",
"tableTo": "feature_requests",
- "columnsFrom": [
- "request_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["request_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -869,9 +796,7 @@
"indexes": {
"idx_feature_request_votes_request": {
"name": "idx_feature_request_votes_request",
- "columns": [
- "request_id"
- ],
+ "columns": ["request_id"],
"isUnique": false
}
},
@@ -880,22 +805,15 @@
"name": "feature_request_votes_request_id_feature_requests_id_fk",
"tableFrom": "feature_request_votes",
"tableTo": "feature_requests",
- "columnsFrom": [
- "request_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["request_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"feature_request_votes_request_id_author_did_pk": {
- "columns": [
- "request_id",
- "author_did"
- ],
+ "columns": ["request_id", "author_did"],
"name": "feature_request_votes_request_id_author_did_pk"
}
},
@@ -1002,24 +920,17 @@
"indexes": {
"idx_feature_requests_sphere_number": {
"name": "idx_feature_requests_sphere_number",
- "columns": [
- "sphere_id",
- "number"
- ],
+ "columns": ["sphere_id", "number"],
"isUnique": true
},
"idx_feature_requests_sphere": {
"name": "idx_feature_requests_sphere",
- "columns": [
- "sphere_id"
- ],
+ "columns": ["sphere_id"],
"isUnique": false
},
"idx_feature_requests_status": {
"name": "idx_feature_requests_status",
- "columns": [
- "status"
- ],
+ "columns": ["status"],
"isUnique": false
}
},
@@ -1028,12 +939,8 @@
"name": "feature_requests_sphere_id_spheres_id_fk",
"tableFrom": "feature_requests",
"tableTo": "spheres",
- "columnsFrom": [
- "sphere_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["sphere_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -1092,9 +999,7 @@
"indexes": {
"idx_feed_posts_parent": {
"name": "idx_feed_posts_parent",
- "columns": [
- "parent_id"
- ],
+ "columns": ["parent_id"],
"isUnique": false
}
},
@@ -1153,18 +1058,12 @@
"indexes": {
"idx_kanban_columns_sphere_slug": {
"name": "idx_kanban_columns_sphere_slug",
- "columns": [
- "sphere_id",
- "slug"
- ],
+ "columns": ["sphere_id", "slug"],
"isUnique": true
},
"idx_kanban_columns_sphere_position": {
"name": "idx_kanban_columns_sphere_position",
- "columns": [
- "sphere_id",
- "position"
- ],
+ "columns": ["sphere_id", "position"],
"isUnique": false
}
},
@@ -1173,12 +1072,8 @@
"name": "kanban_columns_sphere_id_spheres_id_fk",
"tableFrom": "kanban_columns",
"tableTo": "spheres",
- "columnsFrom": [
- "sphere_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["sphere_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -1251,17 +1146,12 @@
"indexes": {
"idx_kanban_task_comments_task": {
"name": "idx_kanban_task_comments_task",
- "columns": [
- "task_id"
- ],
+ "columns": ["task_id"],
"isUnique": false
},
"idx_kanban_task_comments_author_task": {
"name": "idx_kanban_task_comments_author_task",
- "columns": [
- "author_did",
- "task_id"
- ],
+ "columns": ["author_did", "task_id"],
"isUnique": false
}
},
@@ -1270,12 +1160,8 @@
"name": "kanban_task_comments_task_id_kanban_tasks_id_fk",
"tableFrom": "kanban_task_comments",
"tableTo": "kanban_tasks",
- "columnsFrom": [
- "task_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["task_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -1326,9 +1212,7 @@
"indexes": {
"idx_kanban_task_status_changes_task": {
"name": "idx_kanban_task_status_changes_task",
- "columns": [
- "task_id"
- ],
+ "columns": ["task_id"],
"isUnique": false
}
},
@@ -1337,12 +1221,8 @@
"name": "kanban_task_status_changes_task_id_kanban_tasks_id_fk",
"tableFrom": "kanban_task_status_changes",
"tableTo": "kanban_tasks",
- "columnsFrom": [
- "task_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["task_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -1460,33 +1340,22 @@
"indexes": {
"idx_kanban_tasks_sphere_number": {
"name": "idx_kanban_tasks_sphere_number",
- "columns": [
- "sphere_id",
- "number"
- ],
+ "columns": ["sphere_id", "number"],
"isUnique": true
},
"idx_kanban_tasks_sphere": {
"name": "idx_kanban_tasks_sphere",
- "columns": [
- "sphere_id"
- ],
+ "columns": ["sphere_id"],
"isUnique": false
},
"idx_kanban_tasks_status": {
"name": "idx_kanban_tasks_status",
- "columns": [
- "status"
- ],
+ "columns": ["status"],
"isUnique": false
},
"idx_kanban_tasks_sphere_status_position": {
"name": "idx_kanban_tasks_sphere_status_position",
- "columns": [
- "sphere_id",
- "status",
- "position"
- ],
+ "columns": ["sphere_id", "status", "position"],
"isUnique": false
}
},
@@ -1495,12 +1364,8 @@
"name": "kanban_tasks_sphere_id_spheres_id_fk",
"tableFrom": "kanban_tasks",
"tableTo": "spheres",
- "columnsFrom": [
- "sphere_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["sphere_id"],
+ "columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -1520,4 +1385,4 @@
"internal": {
"indexes": {}
}
-}
\ No newline at end of file
+}
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index f773f6a..389910c 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -50,6 +50,13 @@
"when": 1776417674886,
"tag": "0006_melodic_menace",
"breakpoints": true
+ },
+ {
+ "idx": 7,
+ "version": "6",
+ "when": 1776681600000,
+ "tag": "0007_label_pds_records",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/packages/app/e2e/seed.ts b/packages/app/e2e/seed.ts
index 7feecd7..76654b4 100644
--- a/packages/app/e2e/seed.ts
+++ b/packages/app/e2e/seed.ts
@@ -222,15 +222,7 @@ const MEMBER_DID = "did:plc:e2e-test-member";
db.run(
`INSERT INTO feature_requests (id, sphere_id, number, title, description, status, author_did)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
- [
- fr.id,
- "sphere-alpha",
- fr.number,
- fr.title,
- fr.description,
- fr.status,
- fr.authorDid,
- ],
+ [fr.id, "sphere-alpha", fr.number, fr.title, fr.description, fr.status, fr.authorDid],
);
}
@@ -287,15 +279,7 @@ const MEMBER_DID = "did:plc:e2e-test-member";
db.run(
`INSERT INTO feature_requests (id, sphere_id, number, title, description, status, author_did)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
- [
- fr.id,
- "sphere-beta",
- fr.number,
- fr.title,
- fr.description,
- fr.status,
- fr.authorDid,
- ],
+ [fr.id, "sphere-beta", fr.number, fr.title, fr.description, fr.status, fr.authorDid],
);
}
diff --git a/packages/app/src/pages/sphere-labels.tsx b/packages/app/src/pages/sphere-labels.tsx
index 71677e2..d5f8fe4 100644
--- a/packages/app/src/pages/sphere-labels.tsx
+++ b/packages/app/src/pages/sphere-labels.tsx
@@ -272,88 +272,88 @@ function LabelsContent({ handle }: { handle: string }) {
data-drag-item
>
{editingId.value === label.id ? (
-
- ) : (
-
-
-
+
+ ) : (
-
- {confirmingDeleteId.value === label.id ? (
- <>
+
+
+
+
+ {label.description && (
+
+ {label.description}
+
+ )}
+
+
+
+
+ {confirmingDeleteId.value === label.id ? (
+ <>
+
+
+ >
+ ) : (
-
- >
- ) : (
-
- )}
+ )}
+
-
- )}
+ )}
))}
diff --git a/packages/core/src/db/migrate.ts b/packages/core/src/db/migrate.ts
index 0c14a9c..65b43ff 100644
--- a/packages/core/src/db/migrate.ts
+++ b/packages/core/src/db/migrate.ts
@@ -84,4 +84,3 @@ function seedEntryCounters() {
db.run("PRAGMA foreign_keys = ON;");
db.close();
}
-
diff --git a/packages/core/src/db/schema/index.ts b/packages/core/src/db/schema/index.ts
index c51c32d..7b52db5 100644
--- a/packages/core/src/db/schema/index.ts
+++ b/packages/core/src/db/schema/index.ts
@@ -3,5 +3,5 @@ export type { Sphere, SphereMember, SphereModule, SpherePermission } from "./sph
export { oauthStates, oauthSessions } from "./auth.ts";
export { indexerCursor } from "./cursor.ts";
export { sphereEntryCounter } from "./entry-counter.ts";
-export { sphereLabels, entityLabels } from "./labels.ts";
+export { sphereLabels, entityLabels, labelPdsRecords } from "./labels.ts";
export type { SphereLabel, EntityLabel, EntityType } from "./labels.ts";
diff --git a/packages/core/src/db/schema/labels.ts b/packages/core/src/db/schema/labels.ts
index 93d2de9..737d284 100644
--- a/packages/core/src/db/schema/labels.ts
+++ b/packages/core/src/db/schema/labels.ts
@@ -46,6 +46,18 @@ export const entityLabels = sqliteTable(
],
);
+/** Tracks which PDS record rkey each user used for label assignments on a given entity. */
+export const labelPdsRecords = sqliteTable(
+ "label_pds_records",
+ {
+ entityId: text("entity_id").notNull(),
+ entityType: text("entity_type", { enum: ["feature-request", "kanban-task"] }).notNull(),
+ actorDid: text("actor_did").notNull(),
+ rkey: text("rkey").notNull(),
+ },
+ (table) => [primaryKey({ columns: [table.entityId, table.entityType, table.actorDid] })],
+);
+
export type SphereLabel = InferSelectModel;
export type EntityLabel = InferSelectModel;
export type EntityType = EntityLabel["entityType"];
diff --git a/packages/core/src/generated/lexicon-records.ts b/packages/core/src/generated/lexicon-records.ts
index a60e47a..76fbc8c 100644
--- a/packages/core/src/generated/lexicon-records.ts
+++ b/packages/core/src/generated/lexicon-records.ts
@@ -107,6 +107,8 @@ export interface SphereLabelRecord {
subject: string;
/** Label names assigned to the entity. Replaces any previous set. */
labels: string[];
+ /** datetime */
+ updatedAt?: string;
}
export interface SphereMemberRecord {
diff --git a/packages/core/src/sphere/index.ts b/packages/core/src/sphere/index.ts
index 6ea0af5..d9fd3ec 100644
--- a/packages/core/src/sphere/index.ts
+++ b/packages/core/src/sphere/index.ts
@@ -18,6 +18,8 @@ export {
getLabelsForEntities,
setEntityLabels,
resolveLabelIdsByName,
+ getOrCreateLabelRkey,
+ upsertLabelRkey,
} from "./label-operations.ts";
export type { LabelInfo } from "./label-operations.ts";
export { setEntityLabelsSchema } from "./schemas.ts";
diff --git a/packages/core/src/sphere/label-operations.ts b/packages/core/src/sphere/label-operations.ts
index 74bcd11..a7983e5 100644
--- a/packages/core/src/sphere/label-operations.ts
+++ b/packages/core/src/sphere/label-operations.ts
@@ -1,6 +1,6 @@
import { eq, and, max, inArray, asc, count } from "../db/drizzle.ts";
import { getDb } from "../db/index.ts";
-import { sphereLabels, entityLabels } from "../db/schema/index.ts";
+import { sphereLabels, entityLabels, labelPdsRecords } from "../db/schema/index.ts";
import type { SphereLabel, EntityType } from "../db/schema/index.ts";
import { generateRkey } from "../pds.ts";
@@ -134,6 +134,49 @@ export function getLabelsForEntities(
return result;
}
+/** Get or create a stable rkey for a user's label PDS record on an entity. */
+export function getOrCreateLabelRkey(
+ entityId: string,
+ entityType: EntityType,
+ actorDid: string,
+): string {
+ const db = getDb();
+ const existing = db
+ .select({ rkey: labelPdsRecords.rkey })
+ .from(labelPdsRecords)
+ .where(
+ and(
+ eq(labelPdsRecords.entityId, entityId),
+ eq(labelPdsRecords.entityType, entityType),
+ eq(labelPdsRecords.actorDid, actorDid),
+ ),
+ )
+ .get();
+
+ if (existing) return existing.rkey;
+
+ const rkey = generateRkey();
+ db.insert(labelPdsRecords).values({ entityId, entityType, actorDid, rkey }).run();
+ return rkey;
+}
+
+/** Save a label PDS record rkey received from the network. */
+export function upsertLabelRkey(
+ entityId: string,
+ entityType: EntityType,
+ actorDid: string,
+ rkey: string,
+): void {
+ getDb()
+ .insert(labelPdsRecords)
+ .values({ entityId, entityType, actorDid, rkey })
+ .onConflictDoUpdate({
+ target: [labelPdsRecords.entityId, labelPdsRecords.entityType, labelPdsRecords.actorDid],
+ set: { rkey },
+ })
+ .run();
+}
+
/** Replace all labels for an entity with the given label IDs.
* Validates that every label belongs to the specified sphere. */
export function setEntityLabels(
diff --git a/packages/core/src/sphere/operations.ts b/packages/core/src/sphere/operations.ts
index 5645f91..a0d689f 100644
--- a/packages/core/src/sphere/operations.ts
+++ b/packages/core/src/sphere/operations.ts
@@ -2,6 +2,7 @@ import { eq, and, like } from "drizzle-orm";
import { getDb } from "../db/index.ts";
import { spheres, sphereMembers, spherePermissions } from "../db/schema/index.ts";
import { parseAtUri } from "../indexer/uri.ts";
+import { tidToDate } from "../pds.ts";
import { ROLES } from "../permissions/roles.ts";
import { checkPermission } from "../permissions/check.ts";
@@ -302,7 +303,8 @@ export type LabelHandler = (
subjectUri: string,
labelNames: string[],
actorDid: string,
- tid: string,
+ rkey: string,
+ updatedAt: string,
) => boolean;
const labelHandlers: LabelHandler[] = [];
@@ -319,9 +321,10 @@ export function indexLabelAssignment(
): void {
const subjectUri = record.subject as string;
const labels = record.labels as string[];
+ const updatedAt = (record.updatedAt as string) ?? tidToDate(rkey);
if (!subjectUri || !Array.isArray(labels)) return;
for (const handler of labelHandlers) {
- if (handler(subjectUri, labels, did, rkey)) return;
+ if (handler(subjectUri, labels, did, rkey, updatedAt)) return;
}
}
diff --git a/packages/feature-requests/src/api/requests.ts b/packages/feature-requests/src/api/requests.ts
index fcdf0d7..4221ebf 100644
--- a/packages/feature-requests/src/api/requests.ts
+++ b/packages/feature-requests/src/api/requests.ts
@@ -21,6 +21,7 @@ import {
getLabelsForEntities,
setEntityLabels,
setEntityLabelsSchema,
+ getOrCreateLabelRkey,
} from "@exosphere/core/sphere";
const COLLECTION = "site.exosphere.featureRequest.entry";
@@ -201,14 +202,19 @@ app.post("/", requireAuth, requirePermission("feature-requests", "create"), asyn
// Write label PDS record if labels were set and entity has a PDS URI
if (pdsUri && labels.length > 0) {
const session = c.var.session;
- const labelTid = generateRkey();
- const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, {
+ const now = new Date().toISOString();
+ const labelRkey = getOrCreateLabelRkey(id, "feature-request", did);
+ const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, {
subject: pdsUri,
labels: labels.map((l) => l.name),
+ updatedAt: now,
});
if (labelPdsUri) {
const db = getDb();
- db.update(featureRequests).set({ labelTid }).where(eq(featureRequests.id, id)).run();
+ db.update(featureRequests)
+ .set({ labelUpdatedAt: now })
+ .where(eq(featureRequests.id, id))
+ .run();
}
}
@@ -438,16 +444,21 @@ app.post("/:id/labels", requireAuth, async (c) => {
setEntityLabels(sphereId, id, "feature-request", parsed.data.labelIds);
const labels = getLabelsForEntities([id], "feature-request").get(id) ?? [];
- // Write label PDS record
+ // Write label PDS record (reuse existing rkey if this user already wrote one)
if (c.var.sphereVisibility === "public" && existing.pdsUri) {
const session = c.var.session;
- const labelTid = generateRkey();
- const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, {
+ const now = new Date().toISOString();
+ const labelRkey = getOrCreateLabelRkey(id, "feature-request", did);
+ const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, {
subject: existing.pdsUri,
labels: labels.map((l) => l.name),
+ updatedAt: now,
});
if (labelPdsUri) {
- db.update(featureRequests).set({ labelTid }).where(eq(featureRequests.id, id)).run();
+ db.update(featureRequests)
+ .set({ labelUpdatedAt: now })
+ .where(eq(featureRequests.id, id))
+ .run();
}
}
diff --git a/packages/feature-requests/src/db/schema.ts b/packages/feature-requests/src/db/schema.ts
index 5e4d29e..eba02fc 100644
--- a/packages/feature-requests/src/db/schema.ts
+++ b/packages/feature-requests/src/db/schema.ts
@@ -29,7 +29,7 @@ export const featureRequests = sqliteTable(
pdsUri: text("pds_uri"),
hiddenAt: text("hidden_at"),
moderatedBy: text("moderated_by"),
- labelTid: text("label_tid"),
+ labelUpdatedAt: text("label_updated_at"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(datetime('now'))`),
diff --git a/packages/feature-requests/src/indexer.ts b/packages/feature-requests/src/indexer.ts
index 9452e31..8a79a1b 100644
--- a/packages/feature-requests/src/indexer.ts
+++ b/packages/feature-requests/src/indexer.ts
@@ -6,6 +6,7 @@ import {
getActiveMemberRole,
setEntityLabels,
resolveLabelIdsByName,
+ upsertLabelRkey,
} from "@exosphere/core/sphere";
import { checkPermission } from "@exosphere/core/permissions";
import { getDb } from "@exosphere/core/db";
@@ -38,7 +39,7 @@ const STATUS_COLLECTION = "site.exosphere.featureRequest.status";
registerModerationHandler(handleFeatureRequestModeration);
// Register label handler: resolve label PDS records targeting feature requests
-registerLabelHandler((subjectUri, labelNames, actorDid, tid) => {
+registerLabelHandler((subjectUri, labelNames, actorDid, rkey, updatedAt) => {
const parsed = parseAtUri(subjectUri);
if (!parsed || parsed.collection !== COLLECTION) return false;
@@ -48,15 +49,15 @@ registerLabelHandler((subjectUri, labelNames, actorDid, tid) => {
id: featureRequests.id,
sphereId: featureRequests.sphereId,
authorDid: featureRequests.authorDid,
- labelTid: featureRequests.labelTid,
+ labelUpdatedAt: featureRequests.labelUpdatedAt,
})
.from(featureRequests)
.where(eq(featureRequests.id, parsed.rkey))
.get();
if (!fr) return false;
- // TID-based ordering: skip if we already have a newer label record
- if (fr.labelTid && fr.labelTid >= tid) return true;
+ // Timestamp-based ordering: skip if we already have a newer label record
+ if (fr.labelUpdatedAt && fr.labelUpdatedAt >= updatedAt) return true;
// Permission check: author can always set labels, otherwise need changeStatus
if (fr.authorDid !== actorDid) {
@@ -66,7 +67,11 @@ registerLabelHandler((subjectUri, labelNames, actorDid, tid) => {
const labelIds = resolveLabelIdsByName(fr.sphereId, labelNames);
setEntityLabels(fr.sphereId, fr.id, "feature-request", labelIds);
- db.update(featureRequests).set({ labelTid: tid }).where(eq(featureRequests.id, fr.id)).run();
+ upsertLabelRkey(fr.id, "feature-request", actorDid, rkey);
+ db.update(featureRequests)
+ .set({ labelUpdatedAt: updatedAt })
+ .where(eq(featureRequests.id, fr.id))
+ .run();
return true;
});
diff --git a/packages/feature-requests/src/types.ts b/packages/feature-requests/src/types.ts
index ef1c082..949039d 100644
--- a/packages/feature-requests/src/types.ts
+++ b/packages/feature-requests/src/types.ts
@@ -1,10 +1,6 @@
export type { FeatureRequest, FeatureRequestComment, FeatureRequestStatus } from "./db/schema.ts";
export type { Status, SettableStatus } from "./schemas/feature-request.ts";
-export {
- statuses,
- settableStatuses,
- statusLabels,
-} from "./schemas/feature-request.ts";
+export { statuses, settableStatuses, statusLabels } from "./schemas/feature-request.ts";
import type { FeatureRequest, FeatureRequestComment } from "./db/schema.ts";
import type { LabelInfo } from "@exosphere/core/sphere";
diff --git a/packages/kanban/src/api/tasks.ts b/packages/kanban/src/api/tasks.ts
index 437796a..b1e2c50 100644
--- a/packages/kanban/src/api/tasks.ts
+++ b/packages/kanban/src/api/tasks.ts
@@ -32,6 +32,7 @@ import {
getLabelsForEntities,
setEntityLabels,
setEntityLabelsSchema,
+ getOrCreateLabelRkey,
} from "@exosphere/core/sphere";
const COLLECTION = "site.exosphere.kanban.entry";
@@ -220,14 +221,16 @@ app.post("/", requireAuth, requirePermission(MODULE, "create"), async (c) => {
// Write label PDS record if labels were set and entity has a PDS URI
if (pdsUri && labels.length > 0) {
const session = c.var.session;
- const labelTid = generateRkey();
- const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, {
+ const now = new Date().toISOString();
+ const labelRkey = getOrCreateLabelRkey(id, "kanban-task", did);
+ const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, {
subject: pdsUri,
labels: labels.map((l) => l.name),
+ updatedAt: now,
});
if (labelPdsUri) {
const db = getDb();
- db.update(kanbanTasks).set({ labelTid }).where(eq(kanbanTasks.id, id)).run();
+ db.update(kanbanTasks).set({ labelUpdatedAt: now }).where(eq(kanbanTasks.id, id)).run();
}
}
@@ -616,16 +619,18 @@ app.post("/:id/labels", requireAuth, async (c) => {
setEntityLabels(sphereId, id, "kanban-task", parsed.data.labelIds);
const labels = getLabelsForEntities([id], "kanban-task").get(id) ?? [];
- // Write label PDS record
+ // Write label PDS record (reuse existing rkey if this user already wrote one)
if (c.var.sphereVisibility === "public" && existing.pdsUri) {
const session = c.var.session;
- const labelTid = generateRkey();
- const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, {
+ const now = new Date().toISOString();
+ const labelRkey = getOrCreateLabelRkey(id, "kanban-task", did);
+ const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, {
subject: existing.pdsUri,
labels: labels.map((l) => l.name),
+ updatedAt: now,
});
if (labelPdsUri) {
- db.update(kanbanTasks).set({ labelTid }).where(eq(kanbanTasks.id, id)).run();
+ db.update(kanbanTasks).set({ labelUpdatedAt: now }).where(eq(kanbanTasks.id, id)).run();
}
}
diff --git a/packages/kanban/src/db/schema.ts b/packages/kanban/src/db/schema.ts
index 54caaed..571cb35 100644
--- a/packages/kanban/src/db/schema.ts
+++ b/packages/kanban/src/db/schema.ts
@@ -40,7 +40,7 @@ export const kanbanTasks = sqliteTable(
pdsUri: text("pds_uri"),
hiddenAt: text("hidden_at"),
moderatedBy: text("moderated_by"),
- labelTid: text("label_tid"),
+ labelUpdatedAt: text("label_updated_at"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(datetime('now'))`),
diff --git a/packages/kanban/src/indexer.ts b/packages/kanban/src/indexer.ts
index e5f028e..05553bf 100644
--- a/packages/kanban/src/indexer.ts
+++ b/packages/kanban/src/indexer.ts
@@ -6,6 +6,7 @@ import {
getActiveMemberRole,
setEntityLabels,
resolveLabelIdsByName,
+ upsertLabelRkey,
} from "@exosphere/core/sphere";
import { checkPermission } from "@exosphere/core/permissions";
import { getDb } from "@exosphere/core/db";
@@ -32,7 +33,7 @@ const STATUS_COLLECTION = "site.exosphere.kanban.status";
registerModerationHandler(handleKanbanModeration);
// Register label handler: resolve label PDS records targeting kanban tasks
-registerLabelHandler((subjectUri, labelNames, actorDid, tid) => {
+registerLabelHandler((subjectUri, labelNames, actorDid, rkey, updatedAt) => {
const parsed = parseAtUri(subjectUri);
if (!parsed || parsed.collection !== COLLECTION) return false;
@@ -42,15 +43,15 @@ registerLabelHandler((subjectUri, labelNames, actorDid, tid) => {
id: kanbanTasks.id,
sphereId: kanbanTasks.sphereId,
authorDid: kanbanTasks.authorDid,
- labelTid: kanbanTasks.labelTid,
+ labelUpdatedAt: kanbanTasks.labelUpdatedAt,
})
.from(kanbanTasks)
.where(eq(kanbanTasks.id, parsed.rkey))
.get();
if (!task) return false;
- // TID-based ordering: skip if we already have a newer label record
- if (task.labelTid && task.labelTid >= tid) return true;
+ // Timestamp-based ordering: skip if we already have a newer label record
+ if (task.labelUpdatedAt && task.labelUpdatedAt >= updatedAt) return true;
// Permission check: author can always set labels, otherwise need manageTasks
if (task.authorDid !== actorDid) {
@@ -60,7 +61,11 @@ registerLabelHandler((subjectUri, labelNames, actorDid, tid) => {
const labelIds = resolveLabelIdsByName(task.sphereId, labelNames);
setEntityLabels(task.sphereId, task.id, "kanban-task", labelIds);
- db.update(kanbanTasks).set({ labelTid: tid }).where(eq(kanbanTasks.id, task.id)).run();
+ upsertLabelRkey(task.id, "kanban-task", actorDid, rkey);
+ db.update(kanbanTasks)
+ .set({ labelUpdatedAt: updatedAt })
+ .where(eq(kanbanTasks.id, task.id))
+ .run();
return true;
});
diff --git a/packages/kanban/src/ui/components/column-manager.tsx b/packages/kanban/src/ui/components/column-manager.tsx
index fb6cdcb..b6bec55 100644
--- a/packages/kanban/src/ui/components/column-manager.tsx
+++ b/packages/kanban/src/ui/components/column-manager.tsx
@@ -156,81 +156,81 @@ export function ColumnManager({
- {editingId.value === col.id ? (
- (editLabel.value = (e.target as HTMLInputElement).value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") handleRename(col.id);
- if (e.key === "Escape") editingId.value = null;
- }}
- autoFocus
- />
- ) : (
-
- {col.label} ({col.slug})
-
- )}
+ {editingId.value === col.id ? (
+ (editLabel.value = (e.target as HTMLInputElement).value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleRename(col.id);
+ if (e.key === "Escape") editingId.value = null;
+ }}
+ autoFocus
+ />
+ ) : (
+
+ {col.label} ({col.slug})
+
+ )}
- {editingId.value === col.id ? (
- <>
+ {editingId.value === col.id ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+ {columns.length > 1 && (
+
+ )}
+ >
+ )}
+
+
+ {deletingId.value === col.id && (
+
+ Move tasks to:
+
-
)}
-
- {deletingId.value === col.id && (
-
- Move tasks to:
-
- handleDelete(col.id)}
- >
- Confirm delete
-
- (deletingId.value = null)}>
- Cancel
-
-
- )}
-
))}
{dropIndex.value === columns.length && }
diff --git a/packages/kanban/src/ui/ui.css.ts b/packages/kanban/src/ui/ui.css.ts
index 28676ac..d8c31f5 100644
--- a/packages/kanban/src/ui/ui.css.ts
+++ b/packages/kanban/src/ui/ui.css.ts
@@ -155,7 +155,6 @@ export const detailContent = style({
gap: vars.space.xl,
});
-
export const titleRow = style({
display: "flex",
alignItems: "center",