Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: handle labels updateAt

Hugo bc811ff7 b8cd437a

+343 -390
+15
drizzle/0007_label_pds_records.sql
··· 1 + CREATE TABLE `label_pds_records` ( 2 + `entity_id` text NOT NULL, 3 + `entity_type` text NOT NULL, 4 + `actor_did` text NOT NULL, 5 + `rkey` text NOT NULL, 6 + PRIMARY KEY(`entity_id`, `entity_type`, `actor_did`) 7 + ); 8 + --> statement-breakpoint 9 + ALTER TABLE `feature_requests` RENAME COLUMN `label_tid` TO `label_updated_at`; 10 + --> statement-breakpoint 11 + ALTER TABLE `kanban_tasks` RENAME COLUMN `label_tid` TO `label_updated_at`; 12 + --> statement-breakpoint 13 + UPDATE `feature_requests` SET `label_updated_at` = NULL WHERE `label_updated_at` IS NOT NULL; 14 + --> statement-breakpoint 15 + UPDATE `kanban_tasks` SET `label_updated_at` = NULL WHERE `label_updated_at` IS NOT NULL;
+60 -195
drizzle/meta/0006_snapshot.json
··· 134 134 "name": "sphere_entry_counter_sphere_id_spheres_id_fk", 135 135 "tableFrom": "sphere_entry_counter", 136 136 "tableTo": "spheres", 137 - "columnsFrom": [ 138 - "sphere_id" 139 - ], 140 - "columnsTo": [ 141 - "id" 142 - ], 137 + "columnsFrom": ["sphere_id"], 138 + "columnsTo": ["id"], 143 139 "onDelete": "no action", 144 140 "onUpdate": "no action" 145 141 } ··· 176 172 "indexes": { 177 173 "idx_entity_labels_entity": { 178 174 "name": "idx_entity_labels_entity", 179 - "columns": [ 180 - "entity_id", 181 - "entity_type" 182 - ], 175 + "columns": ["entity_id", "entity_type"], 183 176 "isUnique": false 184 177 } 185 178 }, ··· 188 181 "name": "entity_labels_label_id_sphere_labels_id_fk", 189 182 "tableFrom": "entity_labels", 190 183 "tableTo": "sphere_labels", 191 - "columnsFrom": [ 192 - "label_id" 193 - ], 194 - "columnsTo": [ 195 - "id" 196 - ], 184 + "columnsFrom": ["label_id"], 185 + "columnsTo": ["id"], 197 186 "onDelete": "cascade", 198 187 "onUpdate": "no action" 199 188 } 200 189 }, 201 190 "compositePrimaryKeys": { 202 191 "entity_labels_label_id_entity_id_pk": { 203 - "columns": [ 204 - "label_id", 205 - "entity_id" 206 - ], 192 + "columns": ["label_id", "entity_id"], 207 193 "name": "entity_labels_label_id_entity_id_pk" 208 194 } 209 195 }, ··· 268 254 "indexes": { 269 255 "idx_sphere_labels_sphere_name": { 270 256 "name": "idx_sphere_labels_sphere_name", 271 - "columns": [ 272 - "sphere_id", 273 - "name" 274 - ], 257 + "columns": ["sphere_id", "name"], 275 258 "isUnique": true 276 259 }, 277 260 "idx_sphere_labels_sphere_position": { 278 261 "name": "idx_sphere_labels_sphere_position", 279 - "columns": [ 280 - "sphere_id", 281 - "position" 282 - ], 262 + "columns": ["sphere_id", "position"], 283 263 "isUnique": false 284 264 } 285 265 }, ··· 288 268 "name": "sphere_labels_sphere_id_spheres_id_fk", 289 269 "tableFrom": "sphere_labels", 290 270 "tableTo": "spheres", 291 - "columnsFrom": [ 292 - "sphere_id" 293 - ], 294 - "columnsTo": [ 295 - "id" 296 - ], 271 + "columnsFrom": ["sphere_id"], 272 + "columnsTo": ["id"], 297 273 "onDelete": "no action", 298 274 "onUpdate": "no action" 299 275 } ··· 368 344 "indexes": { 369 345 "idx_sphere_members_did": { 370 346 "name": "idx_sphere_members_did", 371 - "columns": [ 372 - "did" 373 - ], 347 + "columns": ["did"], 374 348 "isUnique": false 375 349 } 376 350 }, ··· 379 353 "name": "sphere_members_sphere_id_spheres_id_fk", 380 354 "tableFrom": "sphere_members", 381 355 "tableTo": "spheres", 382 - "columnsFrom": [ 383 - "sphere_id" 384 - ], 385 - "columnsTo": [ 386 - "id" 387 - ], 356 + "columnsFrom": ["sphere_id"], 357 + "columnsTo": ["id"], 388 358 "onDelete": "no action", 389 359 "onUpdate": "no action" 390 360 } 391 361 }, 392 362 "compositePrimaryKeys": { 393 363 "sphere_members_sphere_id_did_pk": { 394 - "columns": [ 395 - "sphere_id", 396 - "did" 397 - ], 364 + "columns": ["sphere_id", "did"], 398 365 "name": "sphere_members_sphere_id_did_pk" 399 366 } 400 367 }, ··· 433 400 "name": "sphere_modules_sphere_id_spheres_id_fk", 434 401 "tableFrom": "sphere_modules", 435 402 "tableTo": "spheres", 436 - "columnsFrom": [ 437 - "sphere_id" 438 - ], 439 - "columnsTo": [ 440 - "id" 441 - ], 403 + "columnsFrom": ["sphere_id"], 404 + "columnsTo": ["id"], 442 405 "onDelete": "no action", 443 406 "onUpdate": "no action" 444 407 } 445 408 }, 446 409 "compositePrimaryKeys": { 447 410 "sphere_modules_sphere_id_module_name_pk": { 448 - "columns": [ 449 - "sphere_id", 450 - "module_name" 451 - ], 411 + "columns": ["sphere_id", "module_name"], 452 412 "name": "sphere_modules_sphere_id_module_name_pk" 453 413 } 454 414 }, ··· 494 454 "name": "sphere_permissions_sphere_id_spheres_id_fk", 495 455 "tableFrom": "sphere_permissions", 496 456 "tableTo": "spheres", 497 - "columnsFrom": [ 498 - "sphere_id" 499 - ], 500 - "columnsTo": [ 501 - "id" 502 - ], 457 + "columnsFrom": ["sphere_id"], 458 + "columnsTo": ["id"], 503 459 "onDelete": "no action", 504 460 "onUpdate": "no action" 505 461 } 506 462 }, 507 463 "compositePrimaryKeys": { 508 464 "sphere_permissions_sphere_id_action_key_pk": { 509 - "columns": [ 510 - "sphere_id", 511 - "action_key" 512 - ], 465 + "columns": ["sphere_id", "action_key"], 513 466 "name": "sphere_permissions_sphere_id_action_key_pk" 514 467 } 515 468 }, ··· 589 542 "indexes": { 590 543 "spheres_handle_unique": { 591 544 "name": "spheres_handle_unique", 592 - "columns": [ 593 - "handle" 594 - ], 545 + "columns": ["handle"], 595 546 "isUnique": true 596 547 } 597 548 }, ··· 636 587 "indexes": { 637 588 "idx_feature_request_comment_votes_comment": { 638 589 "name": "idx_feature_request_comment_votes_comment", 639 - "columns": [ 640 - "comment_id" 641 - ], 590 + "columns": ["comment_id"], 642 591 "isUnique": false 643 592 } 644 593 }, ··· 647 596 "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", 648 597 "tableFrom": "feature_request_comment_votes", 649 598 "tableTo": "feature_request_comments", 650 - "columnsFrom": [ 651 - "comment_id" 652 - ], 653 - "columnsTo": [ 654 - "id" 655 - ], 599 + "columnsFrom": ["comment_id"], 600 + "columnsTo": ["id"], 656 601 "onDelete": "no action", 657 602 "onUpdate": "no action" 658 603 } 659 604 }, 660 605 "compositePrimaryKeys": { 661 606 "feature_request_comment_votes_comment_id_author_did_pk": { 662 - "columns": [ 663 - "comment_id", 664 - "author_did" 665 - ], 607 + "columns": ["comment_id", "author_did"], 666 608 "name": "feature_request_comment_votes_comment_id_author_did_pk" 667 609 } 668 610 }, ··· 733 675 "indexes": { 734 676 "idx_feature_request_comments_request": { 735 677 "name": "idx_feature_request_comments_request", 736 - "columns": [ 737 - "request_id" 738 - ], 678 + "columns": ["request_id"], 739 679 "isUnique": false 740 680 }, 741 681 "idx_feature_request_comments_author_request": { 742 682 "name": "idx_feature_request_comments_author_request", 743 - "columns": [ 744 - "author_did", 745 - "request_id" 746 - ], 683 + "columns": ["author_did", "request_id"], 747 684 "isUnique": false 748 685 } 749 686 }, ··· 752 689 "name": "feature_request_comments_request_id_feature_requests_id_fk", 753 690 "tableFrom": "feature_request_comments", 754 691 "tableTo": "feature_requests", 755 - "columnsFrom": [ 756 - "request_id" 757 - ], 758 - "columnsTo": [ 759 - "id" 760 - ], 692 + "columnsFrom": ["request_id"], 693 + "columnsTo": ["id"], 761 694 "onDelete": "no action", 762 695 "onUpdate": "no action" 763 696 } ··· 808 741 "indexes": { 809 742 "idx_feature_request_statuses_request": { 810 743 "name": "idx_feature_request_statuses_request", 811 - "columns": [ 812 - "request_id" 813 - ], 744 + "columns": ["request_id"], 814 745 "isUnique": false 815 746 } 816 747 }, ··· 819 750 "name": "feature_request_statuses_request_id_feature_requests_id_fk", 820 751 "tableFrom": "feature_request_statuses", 821 752 "tableTo": "feature_requests", 822 - "columnsFrom": [ 823 - "request_id" 824 - ], 825 - "columnsTo": [ 826 - "id" 827 - ], 753 + "columnsFrom": ["request_id"], 754 + "columnsTo": ["id"], 828 755 "onDelete": "no action", 829 756 "onUpdate": "no action" 830 757 } ··· 869 796 "indexes": { 870 797 "idx_feature_request_votes_request": { 871 798 "name": "idx_feature_request_votes_request", 872 - "columns": [ 873 - "request_id" 874 - ], 799 + "columns": ["request_id"], 875 800 "isUnique": false 876 801 } 877 802 }, ··· 880 805 "name": "feature_request_votes_request_id_feature_requests_id_fk", 881 806 "tableFrom": "feature_request_votes", 882 807 "tableTo": "feature_requests", 883 - "columnsFrom": [ 884 - "request_id" 885 - ], 886 - "columnsTo": [ 887 - "id" 888 - ], 808 + "columnsFrom": ["request_id"], 809 + "columnsTo": ["id"], 889 810 "onDelete": "no action", 890 811 "onUpdate": "no action" 891 812 } 892 813 }, 893 814 "compositePrimaryKeys": { 894 815 "feature_request_votes_request_id_author_did_pk": { 895 - "columns": [ 896 - "request_id", 897 - "author_did" 898 - ], 816 + "columns": ["request_id", "author_did"], 899 817 "name": "feature_request_votes_request_id_author_did_pk" 900 818 } 901 819 }, ··· 1002 920 "indexes": { 1003 921 "idx_feature_requests_sphere_number": { 1004 922 "name": "idx_feature_requests_sphere_number", 1005 - "columns": [ 1006 - "sphere_id", 1007 - "number" 1008 - ], 923 + "columns": ["sphere_id", "number"], 1009 924 "isUnique": true 1010 925 }, 1011 926 "idx_feature_requests_sphere": { 1012 927 "name": "idx_feature_requests_sphere", 1013 - "columns": [ 1014 - "sphere_id" 1015 - ], 928 + "columns": ["sphere_id"], 1016 929 "isUnique": false 1017 930 }, 1018 931 "idx_feature_requests_status": { 1019 932 "name": "idx_feature_requests_status", 1020 - "columns": [ 1021 - "status" 1022 - ], 933 + "columns": ["status"], 1023 934 "isUnique": false 1024 935 } 1025 936 }, ··· 1028 939 "name": "feature_requests_sphere_id_spheres_id_fk", 1029 940 "tableFrom": "feature_requests", 1030 941 "tableTo": "spheres", 1031 - "columnsFrom": [ 1032 - "sphere_id" 1033 - ], 1034 - "columnsTo": [ 1035 - "id" 1036 - ], 942 + "columnsFrom": ["sphere_id"], 943 + "columnsTo": ["id"], 1037 944 "onDelete": "no action", 1038 945 "onUpdate": "no action" 1039 946 } ··· 1092 999 "indexes": { 1093 1000 "idx_feed_posts_parent": { 1094 1001 "name": "idx_feed_posts_parent", 1095 - "columns": [ 1096 - "parent_id" 1097 - ], 1002 + "columns": ["parent_id"], 1098 1003 "isUnique": false 1099 1004 } 1100 1005 }, ··· 1153 1058 "indexes": { 1154 1059 "idx_kanban_columns_sphere_slug": { 1155 1060 "name": "idx_kanban_columns_sphere_slug", 1156 - "columns": [ 1157 - "sphere_id", 1158 - "slug" 1159 - ], 1061 + "columns": ["sphere_id", "slug"], 1160 1062 "isUnique": true 1161 1063 }, 1162 1064 "idx_kanban_columns_sphere_position": { 1163 1065 "name": "idx_kanban_columns_sphere_position", 1164 - "columns": [ 1165 - "sphere_id", 1166 - "position" 1167 - ], 1066 + "columns": ["sphere_id", "position"], 1168 1067 "isUnique": false 1169 1068 } 1170 1069 }, ··· 1173 1072 "name": "kanban_columns_sphere_id_spheres_id_fk", 1174 1073 "tableFrom": "kanban_columns", 1175 1074 "tableTo": "spheres", 1176 - "columnsFrom": [ 1177 - "sphere_id" 1178 - ], 1179 - "columnsTo": [ 1180 - "id" 1181 - ], 1075 + "columnsFrom": ["sphere_id"], 1076 + "columnsTo": ["id"], 1182 1077 "onDelete": "no action", 1183 1078 "onUpdate": "no action" 1184 1079 } ··· 1251 1146 "indexes": { 1252 1147 "idx_kanban_task_comments_task": { 1253 1148 "name": "idx_kanban_task_comments_task", 1254 - "columns": [ 1255 - "task_id" 1256 - ], 1149 + "columns": ["task_id"], 1257 1150 "isUnique": false 1258 1151 }, 1259 1152 "idx_kanban_task_comments_author_task": { 1260 1153 "name": "idx_kanban_task_comments_author_task", 1261 - "columns": [ 1262 - "author_did", 1263 - "task_id" 1264 - ], 1154 + "columns": ["author_did", "task_id"], 1265 1155 "isUnique": false 1266 1156 } 1267 1157 }, ··· 1270 1160 "name": "kanban_task_comments_task_id_kanban_tasks_id_fk", 1271 1161 "tableFrom": "kanban_task_comments", 1272 1162 "tableTo": "kanban_tasks", 1273 - "columnsFrom": [ 1274 - "task_id" 1275 - ], 1276 - "columnsTo": [ 1277 - "id" 1278 - ], 1163 + "columnsFrom": ["task_id"], 1164 + "columnsTo": ["id"], 1279 1165 "onDelete": "no action", 1280 1166 "onUpdate": "no action" 1281 1167 } ··· 1326 1212 "indexes": { 1327 1213 "idx_kanban_task_status_changes_task": { 1328 1214 "name": "idx_kanban_task_status_changes_task", 1329 - "columns": [ 1330 - "task_id" 1331 - ], 1215 + "columns": ["task_id"], 1332 1216 "isUnique": false 1333 1217 } 1334 1218 }, ··· 1337 1221 "name": "kanban_task_status_changes_task_id_kanban_tasks_id_fk", 1338 1222 "tableFrom": "kanban_task_status_changes", 1339 1223 "tableTo": "kanban_tasks", 1340 - "columnsFrom": [ 1341 - "task_id" 1342 - ], 1343 - "columnsTo": [ 1344 - "id" 1345 - ], 1224 + "columnsFrom": ["task_id"], 1225 + "columnsTo": ["id"], 1346 1226 "onDelete": "no action", 1347 1227 "onUpdate": "no action" 1348 1228 } ··· 1460 1340 "indexes": { 1461 1341 "idx_kanban_tasks_sphere_number": { 1462 1342 "name": "idx_kanban_tasks_sphere_number", 1463 - "columns": [ 1464 - "sphere_id", 1465 - "number" 1466 - ], 1343 + "columns": ["sphere_id", "number"], 1467 1344 "isUnique": true 1468 1345 }, 1469 1346 "idx_kanban_tasks_sphere": { 1470 1347 "name": "idx_kanban_tasks_sphere", 1471 - "columns": [ 1472 - "sphere_id" 1473 - ], 1348 + "columns": ["sphere_id"], 1474 1349 "isUnique": false 1475 1350 }, 1476 1351 "idx_kanban_tasks_status": { 1477 1352 "name": "idx_kanban_tasks_status", 1478 - "columns": [ 1479 - "status" 1480 - ], 1353 + "columns": ["status"], 1481 1354 "isUnique": false 1482 1355 }, 1483 1356 "idx_kanban_tasks_sphere_status_position": { 1484 1357 "name": "idx_kanban_tasks_sphere_status_position", 1485 - "columns": [ 1486 - "sphere_id", 1487 - "status", 1488 - "position" 1489 - ], 1358 + "columns": ["sphere_id", "status", "position"], 1490 1359 "isUnique": false 1491 1360 } 1492 1361 }, ··· 1495 1364 "name": "kanban_tasks_sphere_id_spheres_id_fk", 1496 1365 "tableFrom": "kanban_tasks", 1497 1366 "tableTo": "spheres", 1498 - "columnsFrom": [ 1499 - "sphere_id" 1500 - ], 1501 - "columnsTo": [ 1502 - "id" 1503 - ], 1367 + "columnsFrom": ["sphere_id"], 1368 + "columnsTo": ["id"], 1504 1369 "onDelete": "no action", 1505 1370 "onUpdate": "no action" 1506 1371 } ··· 1520 1385 "internal": { 1521 1386 "indexes": {} 1522 1387 } 1523 - } 1388 + }
+8 -1
drizzle/meta/_journal.json
··· 50 50 "when": 1776417674886, 51 51 "tag": "0006_melodic_menace", 52 52 "breakpoints": true 53 + }, 54 + { 55 + "idx": 7, 56 + "version": "6", 57 + "when": 1776681600000, 58 + "tag": "0007_label_pds_records", 59 + "breakpoints": true 53 60 } 54 61 ] 55 - } 62 + }
+2 -18
packages/app/e2e/seed.ts
··· 222 222 db.run( 223 223 `INSERT INTO feature_requests (id, sphere_id, number, title, description, status, author_did) 224 224 VALUES (?, ?, ?, ?, ?, ?, ?)`, 225 - [ 226 - fr.id, 227 - "sphere-alpha", 228 - fr.number, 229 - fr.title, 230 - fr.description, 231 - fr.status, 232 - fr.authorDid, 233 - ], 225 + [fr.id, "sphere-alpha", fr.number, fr.title, fr.description, fr.status, fr.authorDid], 234 226 ); 235 227 } 236 228 ··· 287 279 db.run( 288 280 `INSERT INTO feature_requests (id, sphere_id, number, title, description, status, author_did) 289 281 VALUES (?, ?, ?, ?, ?, ?, ?)`, 290 - [ 291 - fr.id, 292 - "sphere-beta", 293 - fr.number, 294 - fr.title, 295 - fr.description, 296 - fr.status, 297 - fr.authorDid, 298 - ], 282 + [fr.id, "sphere-beta", fr.number, fr.title, fr.description, fr.status, fr.authorDid], 299 283 ); 300 284 } 301 285
+72 -72
packages/app/src/pages/sphere-labels.tsx
··· 272 272 data-drag-item 273 273 > 274 274 {editingId.value === label.id ? ( 275 - <div class={ui.formStack}> 276 - <div> 277 - <label class={ui.label}>Name</label> 278 - <input 279 - class={ui.input} 280 - type="text" 281 - maxLength={50} 282 - value={editName.value} 283 - onInput={(e) => (editName.value = (e.target as HTMLInputElement).value)} 284 - /> 285 - </div> 286 - <div> 287 - <label class={ui.label}>Description</label> 288 - <input 289 - class={ui.input} 290 - type="text" 291 - maxLength={200} 292 - value={editDescription.value} 293 - onInput={(e) => 294 - (editDescription.value = (e.target as HTMLInputElement).value) 295 - } 296 - /> 297 - </div> 298 - <div> 299 - <label class={ui.label}>Color</label> 300 - <ColorPicker 301 - value={editColor.value} 302 - onChange={(c) => (editColor.value = c)} 303 - /> 304 - </div> 305 - <div class={ui.row}> 306 - <button class={ui.button} onClick={handleSave} disabled={saving.value}> 307 - {saving.value ? "Saving..." : "Save"} 308 - </button> 309 - <button class={ui.buttonInline} onClick={cancelEdit}> 310 - Cancel 311 - </button> 312 - </div> 313 - </div> 314 - ) : ( 315 - <div class={ui.row}> 316 - <div class={ui.row}> 317 - <GripVertical size={16} class={cpUi.dragHandle} /> 275 + <div class={ui.formStack}> 318 276 <div> 319 - <LabelBadge label={label} /> 320 - {label.description && ( 321 - <span class={ui.muted} style={{ marginInlineStart: "8px" }}> 322 - {label.description} 323 - </span> 324 - )} 277 + <label class={ui.label}>Name</label> 278 + <input 279 + class={ui.input} 280 + type="text" 281 + maxLength={50} 282 + value={editName.value} 283 + onInput={(e) => (editName.value = (e.target as HTMLInputElement).value)} 284 + /> 285 + </div> 286 + <div> 287 + <label class={ui.label}>Description</label> 288 + <input 289 + class={ui.input} 290 + type="text" 291 + maxLength={200} 292 + value={editDescription.value} 293 + onInput={(e) => 294 + (editDescription.value = (e.target as HTMLInputElement).value) 295 + } 296 + /> 297 + </div> 298 + <div> 299 + <label class={ui.label}>Color</label> 300 + <ColorPicker 301 + value={editColor.value} 302 + onChange={(c) => (editColor.value = c)} 303 + /> 304 + </div> 305 + <div class={ui.row}> 306 + <button class={ui.button} onClick={handleSave} disabled={saving.value}> 307 + {saving.value ? "Saving..." : "Save"} 308 + </button> 309 + <button class={ui.buttonInline} onClick={cancelEdit}> 310 + Cancel 311 + </button> 325 312 </div> 326 313 </div> 314 + ) : ( 327 315 <div class={ui.row}> 328 - <button class={ui.buttonInline} onClick={() => startEdit(label)}> 329 - Edit 330 - </button> 331 - {confirmingDeleteId.value === label.id ? ( 332 - <> 316 + <div class={ui.row}> 317 + <GripVertical size={16} class={cpUi.dragHandle} /> 318 + <div> 319 + <LabelBadge label={label} /> 320 + {label.description && ( 321 + <span class={ui.muted} style={{ marginInlineStart: "8px" }}> 322 + {label.description} 323 + </span> 324 + )} 325 + </div> 326 + </div> 327 + <div class={ui.row}> 328 + <button class={ui.buttonInline} onClick={() => startEdit(label)}> 329 + Edit 330 + </button> 331 + {confirmingDeleteId.value === label.id ? ( 332 + <> 333 + <button 334 + class={ui.buttonDangerInline} 335 + onClick={() => handleDelete(label.id)} 336 + > 337 + Confirm 338 + </button> 339 + <button 340 + class={ui.buttonInline} 341 + onClick={() => (confirmingDeleteId.value = null)} 342 + > 343 + Cancel 344 + </button> 345 + </> 346 + ) : ( 333 347 <button 334 348 class={ui.buttonDangerInline} 335 349 onClick={() => handleDelete(label.id)} 336 350 > 337 - Confirm 338 - </button> 339 - <button 340 - class={ui.buttonInline} 341 - onClick={() => (confirmingDeleteId.value = null)} 342 - > 343 - Cancel 351 + Delete 344 352 </button> 345 - </> 346 - ) : ( 347 - <button 348 - class={ui.buttonDangerInline} 349 - onClick={() => handleDelete(label.id)} 350 - > 351 - Delete 352 - </button> 353 - )} 353 + )} 354 + </div> 354 355 </div> 355 - </div> 356 - )} 356 + )} 357 357 </div> 358 358 </Fragment> 359 359 ))}
-1
packages/core/src/db/migrate.ts
··· 84 84 db.run("PRAGMA foreign_keys = ON;"); 85 85 db.close(); 86 86 } 87 -
+1 -1
packages/core/src/db/schema/index.ts
··· 3 3 export { oauthStates, oauthSessions } from "./auth.ts"; 4 4 export { indexerCursor } from "./cursor.ts"; 5 5 export { sphereEntryCounter } from "./entry-counter.ts"; 6 - export { sphereLabels, entityLabels } from "./labels.ts"; 6 + export { sphereLabels, entityLabels, labelPdsRecords } from "./labels.ts"; 7 7 export type { SphereLabel, EntityLabel, EntityType } from "./labels.ts";
+12
packages/core/src/db/schema/labels.ts
··· 46 46 ], 47 47 ); 48 48 49 + /** Tracks which PDS record rkey each user used for label assignments on a given entity. */ 50 + export const labelPdsRecords = sqliteTable( 51 + "label_pds_records", 52 + { 53 + entityId: text("entity_id").notNull(), 54 + entityType: text("entity_type", { enum: ["feature-request", "kanban-task"] }).notNull(), 55 + actorDid: text("actor_did").notNull(), 56 + rkey: text("rkey").notNull(), 57 + }, 58 + (table) => [primaryKey({ columns: [table.entityId, table.entityType, table.actorDid] })], 59 + ); 60 + 49 61 export type SphereLabel = InferSelectModel<typeof sphereLabels>; 50 62 export type EntityLabel = InferSelectModel<typeof entityLabels>; 51 63 export type EntityType = EntityLabel["entityType"];
+2
packages/core/src/generated/lexicon-records.ts
··· 107 107 subject: string; 108 108 /** Label names assigned to the entity. Replaces any previous set. */ 109 109 labels: string[]; 110 + /** datetime */ 111 + updatedAt?: string; 110 112 } 111 113 112 114 export interface SphereMemberRecord {
+2
packages/core/src/sphere/index.ts
··· 18 18 getLabelsForEntities, 19 19 setEntityLabels, 20 20 resolveLabelIdsByName, 21 + getOrCreateLabelRkey, 22 + upsertLabelRkey, 21 23 } from "./label-operations.ts"; 22 24 export type { LabelInfo } from "./label-operations.ts"; 23 25 export { setEntityLabelsSchema } from "./schemas.ts";
+44 -1
packages/core/src/sphere/label-operations.ts
··· 1 1 import { eq, and, max, inArray, asc, count } from "../db/drizzle.ts"; 2 2 import { getDb } from "../db/index.ts"; 3 - import { sphereLabels, entityLabels } from "../db/schema/index.ts"; 3 + import { sphereLabels, entityLabels, labelPdsRecords } from "../db/schema/index.ts"; 4 4 import type { SphereLabel, EntityType } from "../db/schema/index.ts"; 5 5 import { generateRkey } from "../pds.ts"; 6 6 ··· 132 132 result.set(row.entityId, list); 133 133 } 134 134 return result; 135 + } 136 + 137 + /** Get or create a stable rkey for a user's label PDS record on an entity. */ 138 + export function getOrCreateLabelRkey( 139 + entityId: string, 140 + entityType: EntityType, 141 + actorDid: string, 142 + ): string { 143 + const db = getDb(); 144 + const existing = db 145 + .select({ rkey: labelPdsRecords.rkey }) 146 + .from(labelPdsRecords) 147 + .where( 148 + and( 149 + eq(labelPdsRecords.entityId, entityId), 150 + eq(labelPdsRecords.entityType, entityType), 151 + eq(labelPdsRecords.actorDid, actorDid), 152 + ), 153 + ) 154 + .get(); 155 + 156 + if (existing) return existing.rkey; 157 + 158 + const rkey = generateRkey(); 159 + db.insert(labelPdsRecords).values({ entityId, entityType, actorDid, rkey }).run(); 160 + return rkey; 161 + } 162 + 163 + /** Save a label PDS record rkey received from the network. */ 164 + export function upsertLabelRkey( 165 + entityId: string, 166 + entityType: EntityType, 167 + actorDid: string, 168 + rkey: string, 169 + ): void { 170 + getDb() 171 + .insert(labelPdsRecords) 172 + .values({ entityId, entityType, actorDid, rkey }) 173 + .onConflictDoUpdate({ 174 + target: [labelPdsRecords.entityId, labelPdsRecords.entityType, labelPdsRecords.actorDid], 175 + set: { rkey }, 176 + }) 177 + .run(); 135 178 } 136 179 137 180 /** Replace all labels for an entity with the given label IDs.
+5 -2
packages/core/src/sphere/operations.ts
··· 2 2 import { getDb } from "../db/index.ts"; 3 3 import { spheres, sphereMembers, spherePermissions } from "../db/schema/index.ts"; 4 4 import { parseAtUri } from "../indexer/uri.ts"; 5 + import { tidToDate } from "../pds.ts"; 5 6 import { ROLES } from "../permissions/roles.ts"; 6 7 import { checkPermission } from "../permissions/check.ts"; 7 8 ··· 302 303 subjectUri: string, 303 304 labelNames: string[], 304 305 actorDid: string, 305 - tid: string, 306 + rkey: string, 307 + updatedAt: string, 306 308 ) => boolean; 307 309 308 310 const labelHandlers: LabelHandler[] = []; ··· 319 321 ): void { 320 322 const subjectUri = record.subject as string; 321 323 const labels = record.labels as string[]; 324 + const updatedAt = (record.updatedAt as string) ?? tidToDate(rkey); 322 325 if (!subjectUri || !Array.isArray(labels)) return; 323 326 324 327 for (const handler of labelHandlers) { 325 - if (handler(subjectUri, labels, did, rkey)) return; 328 + if (handler(subjectUri, labels, did, rkey, updatedAt)) return; 326 329 } 327 330 }
+18 -7
packages/feature-requests/src/api/requests.ts
··· 21 21 getLabelsForEntities, 22 22 setEntityLabels, 23 23 setEntityLabelsSchema, 24 + getOrCreateLabelRkey, 24 25 } from "@exosphere/core/sphere"; 25 26 26 27 const COLLECTION = "site.exosphere.featureRequest.entry"; ··· 201 202 // Write label PDS record if labels were set and entity has a PDS URI 202 203 if (pdsUri && labels.length > 0) { 203 204 const session = c.var.session; 204 - const labelTid = generateRkey(); 205 - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, { 205 + const now = new Date().toISOString(); 206 + const labelRkey = getOrCreateLabelRkey(id, "feature-request", did); 207 + const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { 206 208 subject: pdsUri, 207 209 labels: labels.map((l) => l.name), 210 + updatedAt: now, 208 211 }); 209 212 if (labelPdsUri) { 210 213 const db = getDb(); 211 - db.update(featureRequests).set({ labelTid }).where(eq(featureRequests.id, id)).run(); 214 + db.update(featureRequests) 215 + .set({ labelUpdatedAt: now }) 216 + .where(eq(featureRequests.id, id)) 217 + .run(); 212 218 } 213 219 } 214 220 ··· 438 444 setEntityLabels(sphereId, id, "feature-request", parsed.data.labelIds); 439 445 const labels = getLabelsForEntities([id], "feature-request").get(id) ?? []; 440 446 441 - // Write label PDS record 447 + // Write label PDS record (reuse existing rkey if this user already wrote one) 442 448 if (c.var.sphereVisibility === "public" && existing.pdsUri) { 443 449 const session = c.var.session; 444 - const labelTid = generateRkey(); 445 - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, { 450 + const now = new Date().toISOString(); 451 + const labelRkey = getOrCreateLabelRkey(id, "feature-request", did); 452 + const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { 446 453 subject: existing.pdsUri, 447 454 labels: labels.map((l) => l.name), 455 + updatedAt: now, 448 456 }); 449 457 if (labelPdsUri) { 450 - db.update(featureRequests).set({ labelTid }).where(eq(featureRequests.id, id)).run(); 458 + db.update(featureRequests) 459 + .set({ labelUpdatedAt: now }) 460 + .where(eq(featureRequests.id, id)) 461 + .run(); 451 462 } 452 463 } 453 464
+1 -1
packages/feature-requests/src/db/schema.ts
··· 29 29 pdsUri: text("pds_uri"), 30 30 hiddenAt: text("hidden_at"), 31 31 moderatedBy: text("moderated_by"), 32 - labelTid: text("label_tid"), 32 + labelUpdatedAt: text("label_updated_at"), 33 33 updatedAt: text("updated_at") 34 34 .notNull() 35 35 .default(sql`(datetime('now'))`),
+10 -5
packages/feature-requests/src/indexer.ts
··· 6 6 getActiveMemberRole, 7 7 setEntityLabels, 8 8 resolveLabelIdsByName, 9 + upsertLabelRkey, 9 10 } from "@exosphere/core/sphere"; 10 11 import { checkPermission } from "@exosphere/core/permissions"; 11 12 import { getDb } from "@exosphere/core/db"; ··· 38 39 registerModerationHandler(handleFeatureRequestModeration); 39 40 40 41 // Register label handler: resolve label PDS records targeting feature requests 41 - registerLabelHandler((subjectUri, labelNames, actorDid, tid) => { 42 + registerLabelHandler((subjectUri, labelNames, actorDid, rkey, updatedAt) => { 42 43 const parsed = parseAtUri(subjectUri); 43 44 if (!parsed || parsed.collection !== COLLECTION) return false; 44 45 ··· 48 49 id: featureRequests.id, 49 50 sphereId: featureRequests.sphereId, 50 51 authorDid: featureRequests.authorDid, 51 - labelTid: featureRequests.labelTid, 52 + labelUpdatedAt: featureRequests.labelUpdatedAt, 52 53 }) 53 54 .from(featureRequests) 54 55 .where(eq(featureRequests.id, parsed.rkey)) 55 56 .get(); 56 57 if (!fr) return false; 57 58 58 - // TID-based ordering: skip if we already have a newer label record 59 - if (fr.labelTid && fr.labelTid >= tid) return true; 59 + // Timestamp-based ordering: skip if we already have a newer label record 60 + if (fr.labelUpdatedAt && fr.labelUpdatedAt >= updatedAt) return true; 60 61 61 62 // Permission check: author can always set labels, otherwise need changeStatus 62 63 if (fr.authorDid !== actorDid) { ··· 66 67 67 68 const labelIds = resolveLabelIdsByName(fr.sphereId, labelNames); 68 69 setEntityLabels(fr.sphereId, fr.id, "feature-request", labelIds); 69 - db.update(featureRequests).set({ labelTid: tid }).where(eq(featureRequests.id, fr.id)).run(); 70 + upsertLabelRkey(fr.id, "feature-request", actorDid, rkey); 71 + db.update(featureRequests) 72 + .set({ labelUpdatedAt: updatedAt }) 73 + .where(eq(featureRequests.id, fr.id)) 74 + .run(); 70 75 return true; 71 76 }); 72 77
+1 -5
packages/feature-requests/src/types.ts
··· 1 1 export type { FeatureRequest, FeatureRequestComment, FeatureRequestStatus } from "./db/schema.ts"; 2 2 export type { Status, SettableStatus } from "./schemas/feature-request.ts"; 3 - export { 4 - statuses, 5 - settableStatuses, 6 - statusLabels, 7 - } from "./schemas/feature-request.ts"; 3 + export { statuses, settableStatuses, statusLabels } from "./schemas/feature-request.ts"; 8 4 9 5 import type { FeatureRequest, FeatureRequestComment } from "./db/schema.ts"; 10 6 import type { LabelInfo } from "@exosphere/core/sphere";
+12 -7
packages/kanban/src/api/tasks.ts
··· 32 32 getLabelsForEntities, 33 33 setEntityLabels, 34 34 setEntityLabelsSchema, 35 + getOrCreateLabelRkey, 35 36 } from "@exosphere/core/sphere"; 36 37 37 38 const COLLECTION = "site.exosphere.kanban.entry"; ··· 220 221 // Write label PDS record if labels were set and entity has a PDS URI 221 222 if (pdsUri && labels.length > 0) { 222 223 const session = c.var.session; 223 - const labelTid = generateRkey(); 224 - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, { 224 + const now = new Date().toISOString(); 225 + const labelRkey = getOrCreateLabelRkey(id, "kanban-task", did); 226 + const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { 225 227 subject: pdsUri, 226 228 labels: labels.map((l) => l.name), 229 + updatedAt: now, 227 230 }); 228 231 if (labelPdsUri) { 229 232 const db = getDb(); 230 - db.update(kanbanTasks).set({ labelTid }).where(eq(kanbanTasks.id, id)).run(); 233 + db.update(kanbanTasks).set({ labelUpdatedAt: now }).where(eq(kanbanTasks.id, id)).run(); 231 234 } 232 235 } 233 236 ··· 616 619 setEntityLabels(sphereId, id, "kanban-task", parsed.data.labelIds); 617 620 const labels = getLabelsForEntities([id], "kanban-task").get(id) ?? []; 618 621 619 - // Write label PDS record 622 + // Write label PDS record (reuse existing rkey if this user already wrote one) 620 623 if (c.var.sphereVisibility === "public" && existing.pdsUri) { 621 624 const session = c.var.session; 622 - const labelTid = generateRkey(); 623 - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, { 625 + const now = new Date().toISOString(); 626 + const labelRkey = getOrCreateLabelRkey(id, "kanban-task", did); 627 + const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { 624 628 subject: existing.pdsUri, 625 629 labels: labels.map((l) => l.name), 630 + updatedAt: now, 626 631 }); 627 632 if (labelPdsUri) { 628 - db.update(kanbanTasks).set({ labelTid }).where(eq(kanbanTasks.id, id)).run(); 633 + db.update(kanbanTasks).set({ labelUpdatedAt: now }).where(eq(kanbanTasks.id, id)).run(); 629 634 } 630 635 } 631 636
+1 -1
packages/kanban/src/db/schema.ts
··· 40 40 pdsUri: text("pds_uri"), 41 41 hiddenAt: text("hidden_at"), 42 42 moderatedBy: text("moderated_by"), 43 - labelTid: text("label_tid"), 43 + labelUpdatedAt: text("label_updated_at"), 44 44 updatedAt: text("updated_at") 45 45 .notNull() 46 46 .default(sql`(datetime('now'))`),
+10 -5
packages/kanban/src/indexer.ts
··· 6 6 getActiveMemberRole, 7 7 setEntityLabels, 8 8 resolveLabelIdsByName, 9 + upsertLabelRkey, 9 10 } from "@exosphere/core/sphere"; 10 11 import { checkPermission } from "@exosphere/core/permissions"; 11 12 import { getDb } from "@exosphere/core/db"; ··· 32 33 registerModerationHandler(handleKanbanModeration); 33 34 34 35 // Register label handler: resolve label PDS records targeting kanban tasks 35 - registerLabelHandler((subjectUri, labelNames, actorDid, tid) => { 36 + registerLabelHandler((subjectUri, labelNames, actorDid, rkey, updatedAt) => { 36 37 const parsed = parseAtUri(subjectUri); 37 38 if (!parsed || parsed.collection !== COLLECTION) return false; 38 39 ··· 42 43 id: kanbanTasks.id, 43 44 sphereId: kanbanTasks.sphereId, 44 45 authorDid: kanbanTasks.authorDid, 45 - labelTid: kanbanTasks.labelTid, 46 + labelUpdatedAt: kanbanTasks.labelUpdatedAt, 46 47 }) 47 48 .from(kanbanTasks) 48 49 .where(eq(kanbanTasks.id, parsed.rkey)) 49 50 .get(); 50 51 if (!task) return false; 51 52 52 - // TID-based ordering: skip if we already have a newer label record 53 - if (task.labelTid && task.labelTid >= tid) return true; 53 + // Timestamp-based ordering: skip if we already have a newer label record 54 + if (task.labelUpdatedAt && task.labelUpdatedAt >= updatedAt) return true; 54 55 55 56 // Permission check: author can always set labels, otherwise need manageTasks 56 57 if (task.authorDid !== actorDid) { ··· 60 61 61 62 const labelIds = resolveLabelIdsByName(task.sphereId, labelNames); 62 63 setEntityLabels(task.sphereId, task.id, "kanban-task", labelIds); 63 - db.update(kanbanTasks).set({ labelTid: tid }).where(eq(kanbanTasks.id, task.id)).run(); 64 + upsertLabelRkey(task.id, "kanban-task", actorDid, rkey); 65 + db.update(kanbanTasks) 66 + .set({ labelUpdatedAt: updatedAt }) 67 + .where(eq(kanbanTasks.id, task.id)) 68 + .run(); 64 69 return true; 65 70 }); 66 71
+67 -67
packages/kanban/src/ui/components/column-manager.tsx
··· 156 156 <div class={kbUi.columnManagerItem}> 157 157 <GripVertical size={16} class={kbUi.dragHandle} /> 158 158 159 - {editingId.value === col.id ? ( 160 - <input 161 - class={ui.input} 162 - type="text" 163 - maxLength={50} 164 - value={editLabel.value} 165 - onInput={(e) => (editLabel.value = (e.target as HTMLInputElement).value)} 166 - onKeyDown={(e) => { 167 - if (e.key === "Enter") handleRename(col.id); 168 - if (e.key === "Escape") editingId.value = null; 169 - }} 170 - autoFocus 171 - /> 172 - ) : ( 173 - <span class={kbUi.columnManagerLabel}> 174 - {col.label} <span class={kbUi.columnManagerSlug}>({col.slug})</span> 175 - </span> 176 - )} 159 + {editingId.value === col.id ? ( 160 + <input 161 + class={ui.input} 162 + type="text" 163 + maxLength={50} 164 + value={editLabel.value} 165 + onInput={(e) => (editLabel.value = (e.target as HTMLInputElement).value)} 166 + onKeyDown={(e) => { 167 + if (e.key === "Enter") handleRename(col.id); 168 + if (e.key === "Escape") editingId.value = null; 169 + }} 170 + autoFocus 171 + /> 172 + ) : ( 173 + <span class={kbUi.columnManagerLabel}> 174 + {col.label} <span class={kbUi.columnManagerSlug}>({col.slug})</span> 175 + </span> 176 + )} 177 + 178 + {editingId.value === col.id ? ( 179 + <> 180 + <button 181 + class={ui.buttonCompact} 182 + disabled={busy.value || !editLabel.value.trim()} 183 + onClick={() => handleRename(col.id)} 184 + > 185 + Save 186 + </button> 187 + <button class={ui.buttonInline} onClick={() => (editingId.value = null)}> 188 + Cancel 189 + </button> 190 + </> 191 + ) : ( 192 + <> 193 + <button class={ui.buttonInline} onClick={() => startEdit(col)}> 194 + Rename 195 + </button> 196 + {columns.length > 1 && ( 197 + <button class={ui.buttonDangerInline} onClick={() => startDelete(col)}> 198 + Delete 199 + </button> 200 + )} 201 + </> 202 + )} 203 + </div> 177 204 178 - {editingId.value === col.id ? ( 179 - <> 205 + {deletingId.value === col.id && ( 206 + <div class={kbUi.columnManagerItem}> 207 + <span class={ui.muted}>Move tasks to:</span> 208 + <select 209 + class={kbUi.statusSelect} 210 + value={reassignSlug.value} 211 + onChange={(e) => (reassignSlug.value = (e.target as HTMLSelectElement).value)} 212 + > 213 + {columns 214 + .filter((c) => c.id !== col.id) 215 + .map((c) => ( 216 + <option key={c.slug} value={c.slug}> 217 + {c.label} 218 + </option> 219 + ))} 220 + </select> 180 221 <button 181 - class={ui.buttonCompact} 182 - disabled={busy.value || !editLabel.value.trim()} 183 - onClick={() => handleRename(col.id)} 222 + class={ui.buttonDanger} 223 + disabled={busy.value || !reassignSlug.value} 224 + onClick={() => handleDelete(col.id)} 184 225 > 185 - Save 226 + Confirm delete 186 227 </button> 187 - <button class={ui.buttonInline} onClick={() => (editingId.value = null)}> 228 + <button class={ui.buttonInline} onClick={() => (deletingId.value = null)}> 188 229 Cancel 189 230 </button> 190 - </> 191 - ) : ( 192 - <> 193 - <button class={ui.buttonInline} onClick={() => startEdit(col)}> 194 - Rename 195 - </button> 196 - {columns.length > 1 && ( 197 - <button class={ui.buttonDangerInline} onClick={() => startDelete(col)}> 198 - Delete 199 - </button> 200 - )} 201 - </> 231 + </div> 202 232 )} 203 - </div> 204 - 205 - {deletingId.value === col.id && ( 206 - <div class={kbUi.columnManagerItem}> 207 - <span class={ui.muted}>Move tasks to:</span> 208 - <select 209 - class={kbUi.statusSelect} 210 - value={reassignSlug.value} 211 - onChange={(e) => (reassignSlug.value = (e.target as HTMLSelectElement).value)} 212 - > 213 - {columns 214 - .filter((c) => c.id !== col.id) 215 - .map((c) => ( 216 - <option key={c.slug} value={c.slug}> 217 - {c.label} 218 - </option> 219 - ))} 220 - </select> 221 - <button 222 - class={ui.buttonDanger} 223 - disabled={busy.value || !reassignSlug.value} 224 - onClick={() => handleDelete(col.id)} 225 - > 226 - Confirm delete 227 - </button> 228 - <button class={ui.buttonInline} onClick={() => (deletingId.value = null)}> 229 - Cancel 230 - </button> 231 - </div> 232 - )} 233 233 </div> 234 234 </Fragment> 235 235 ))}
-1
packages/kanban/src/ui/ui.css.ts
··· 155 155 gap: vars.space.xl, 156 156 }); 157 157 158 - 159 158 export const titleRow = style({ 160 159 display: "flex", 161 160 alignItems: "center",