@wirecat/cli-core 0.419.0 → 0.421.0

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.
Files changed (126) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli/bot/context.d.ts +3 -2
  3. package/dist/cli/bot/context.js +15 -0
  4. package/dist/cli/config-command.js +1 -2
  5. package/dist/cli/index.d.ts +0 -1
  6. package/dist/cli/index.js +0 -1
  7. package/dist/cli/messenger/context.js +3 -6
  8. package/dist/cli/permission-keys.d.ts +2 -0
  9. package/dist/cli/permission-keys.js +10 -3
  10. package/dist/cli/preview.d.ts +2 -2
  11. package/dist/cli/preview.js +4 -3
  12. package/dist/cli/program.js +4 -8
  13. package/dist/cli/settings.d.ts +0 -2
  14. package/dist/cli/settings.js +1 -5
  15. package/dist/domain/providers.d.ts +20 -2
  16. package/dist/domain/providers.js +23 -3
  17. package/dist/domain/tag-consolidation.d.ts +30 -1
  18. package/dist/entities/index.d.ts +4 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/mcp/surface.js +0 -4
  22. package/dist/mcp/tool.d.ts +1 -3
  23. package/dist/mcp/tool.js +1 -2
  24. package/dist/meetings/calendar-text.d.ts +13 -0
  25. package/dist/meetings/calendar-text.js +57 -0
  26. package/dist/meetings/event-store.d.ts +202 -0
  27. package/dist/meetings/event-store.js +1 -0
  28. package/dist/meetings/events.js +5 -1
  29. package/dist/meetings/index.d.ts +3 -0
  30. package/dist/meetings/index.js +2 -0
  31. package/dist/meetings/join-key.d.ts +7 -0
  32. package/dist/meetings/join-key.js +38 -0
  33. package/dist/meetings/mcp/index.d.ts +15 -15
  34. package/dist/meetings/model.d.ts +8 -2
  35. package/dist/meetings/read-answers.d.ts +6 -2
  36. package/dist/meetings/read-answers.js +9 -2
  37. package/dist/meetings/store.d.ts +2 -0
  38. package/dist/meetings/testing/event-store-contract.d.ts +10 -0
  39. package/dist/meetings/testing/event-store-contract.js +161 -0
  40. package/dist/meetings/testing/index.d.ts +1 -0
  41. package/dist/meetings/testing/index.js +1 -0
  42. package/dist/parity/manifest.js +0 -4
  43. package/dist/search/account-list.js +39 -7
  44. package/dist/sends/index.d.ts +1 -1
  45. package/dist/sends/index.js +1 -1
  46. package/dist/sends/permissions.d.ts +0 -13
  47. package/dist/sends/permissions.js +15 -142
  48. package/dist/services/agenda.js +5 -5
  49. package/dist/services/document-source.d.ts +7 -3
  50. package/dist/services/document-source.js +24 -12
  51. package/dist/services/documents/ingestion.d.ts +6 -1
  52. package/dist/services/documents/ingestion.js +4 -0
  53. package/dist/services/notes-search.js +3 -2
  54. package/dist/services/search-all.js +1 -0
  55. package/dist/services/task-tag-filters.d.ts +3 -0
  56. package/dist/services/task-tag-filters.js +8 -0
  57. package/dist/services/tasks.js +5 -9
  58. package/dist/store/document-transfer/index.js +12 -2
  59. package/dist/store/index.d.ts +2 -1
  60. package/dist/store/sqlite/accounts.js +1 -0
  61. package/dist/store/sqlite/calendar.d.ts +3 -0
  62. package/dist/store/sqlite/calendar.js +593 -0
  63. package/dist/store/sqlite/catalog.js +16 -5
  64. package/dist/store/sqlite/chat-rekey.js +1 -1
  65. package/dist/store/sqlite/chats.js +2 -2
  66. package/dist/store/sqlite/context-document-source.js +8 -4
  67. package/dist/store/sqlite/document-transfer/apply.js +24 -2
  68. package/dist/store/sqlite/document-transfer/collection.d.ts +19 -0
  69. package/dist/store/sqlite/document-transfer/collection.js +66 -0
  70. package/dist/store/sqlite/document-transfer/plan.js +15 -8
  71. package/dist/store/sqlite/document-transfer/snapshot.js +12 -7
  72. package/dist/store/sqlite/entity-types.d.ts +5 -1
  73. package/dist/store/sqlite/entity-types.js +5 -1
  74. package/dist/store/sqlite/events.d.ts +8 -2
  75. package/dist/store/sqlite/events.js +36 -12
  76. package/dist/store/sqlite/files.d.ts +93 -0
  77. package/dist/store/sqlite/files.js +293 -0
  78. package/dist/store/sqlite/folded.d.ts +3 -0
  79. package/dist/store/sqlite/folded.js +1 -0
  80. package/dist/store/sqlite/imported-documents.js +1 -1
  81. package/dist/store/sqlite/join-keys.d.ts +8 -0
  82. package/dist/store/sqlite/join-keys.js +27 -0
  83. package/dist/store/sqlite/knowledge.js +6 -6
  84. package/dist/store/sqlite/link-reviews.d.ts +9 -0
  85. package/dist/store/sqlite/link-reviews.js +12 -0
  86. package/dist/store/sqlite/lucene-fields.js +8 -6
  87. package/dist/store/sqlite/manifest.js +11 -0
  88. package/dist/store/sqlite/meeting-selection.js +1 -1
  89. package/dist/store/sqlite/meetings.js +6 -1
  90. package/dist/store/sqlite/migrations.generated.js +143 -0
  91. package/dist/store/sqlite/note-index.d.ts +8 -1
  92. package/dist/store/sqlite/note-index.js +14 -0
  93. package/dist/store/sqlite/note-search.js +6 -5
  94. package/dist/store/sqlite/notes.d.ts +18 -1
  95. package/dist/store/sqlite/notes.js +237 -40
  96. package/dist/store/sqlite/open-store.js +4 -0
  97. package/dist/store/sqlite/person-links.js +7 -2
  98. package/dist/store/sqlite/schema/canonical-tags.d.ts +87 -0
  99. package/dist/store/sqlite/schema/canonical-tags.js +8 -0
  100. package/dist/store/sqlite/schema/tag-consolidations.d.ts +270 -0
  101. package/dist/store/sqlite/schema/tag-consolidations.js +34 -0
  102. package/dist/store/sqlite/schema.d.ts +3589 -1634
  103. package/dist/store/sqlite/schema.js +191 -13
  104. package/dist/store/sqlite/tag-consolidation-apply.d.ts +3 -0
  105. package/dist/store/sqlite/tag-consolidation-apply.js +88 -0
  106. package/dist/store/sqlite/tag-consolidation-input.d.ts +16 -0
  107. package/dist/store/sqlite/tag-consolidation-input.js +52 -0
  108. package/dist/store/sqlite/tag-consolidation.js +51 -66
  109. package/dist/store/sqlite/tag-creation.js +17 -8
  110. package/dist/store/sqlite/tag-resolution.d.ts +5 -0
  111. package/dist/store/sqlite/tag-resolution.js +33 -0
  112. package/dist/store/sqlite/tags.d.ts +3 -1
  113. package/dist/store/sqlite/tags.js +70 -50
  114. package/dist/store/sqlite/tasks.d.ts +2 -0
  115. package/dist/store/sqlite/tasks.js +6 -1
  116. package/dist/store/sqlite/things.js +2 -1
  117. package/dist/store/sqlite/vocabulary.d.ts +1 -0
  118. package/dist/store/sqlite/vocabulary.js +30 -8
  119. package/dist/store/store.d.ts +6 -0
  120. package/dist/store/store.js +10 -0
  121. package/dist/store/tags.js +2 -2
  122. package/package.json +2 -1
  123. package/dist/cli/moved.d.ts +0 -18
  124. package/dist/cli/moved.js +0 -72
  125. package/dist/sends/permission-aliases.d.ts +0 -5
  126. package/dist/sends/permission-aliases.js +0 -50
@@ -1,4 +1,5 @@
1
1
  import { blob, desc, index, integer, primaryKey, real, sql, sqliteTable, text, unique, uniqueIndex, } from "./drizzle/core.js";
2
+ import { canonicalTags } from "./schema/canonical-tags.js";
2
3
  /**
3
4
  * The store's base tables — what `drizzle-kit generate` diffs against. Every column is explained in
4
5
  * `docs/storage/schema.md`; the two must agree. A change here becomes a migration, under the rules at the
@@ -98,7 +99,11 @@ export const identities = sqliteTable("identities", {
98
99
  createdAt: integer("created_at").notNull(),
99
100
  updatedAt: integer("updated_at").notNull(),
100
101
  description: text("description"),
101
- }, (table) => [unique().on(table.provider, table.externalId)]);
102
+ email: text("email"),
103
+ }, (table) => [
104
+ unique().on(table.provider, table.externalId),
105
+ index("identities_by_email").on(table.email).where(sql `email IS NOT NULL`),
106
+ ]);
102
107
  export const identityLinks = sqliteTable("identity_links", {
103
108
  identityId: integer("identity_id")
104
109
  .primaryKey()
@@ -751,7 +756,38 @@ export const documents = sqliteTable("documents", {
751
756
  createdAt: integer("created_at").notNull(),
752
757
  updatedAt: integer("updated_at").notNull(),
753
758
  deletedAt: integer("deleted_at"),
759
+ pinnedFileId: integer("pinned_file_id"),
754
760
  }, (table) => [unique().on(table.accountId, table.externalId)]);
761
+ export const files = sqliteTable("files", {
762
+ id: integer("id").primaryKey(),
763
+ accountId: integer("account_id")
764
+ .notNull()
765
+ .references(() => accounts.id),
766
+ externalId: text("external_id").notNull(),
767
+ kind: text("kind").notNull(),
768
+ name: text("name").notNull(),
769
+ path: text("path").notNull(),
770
+ pathFolded: text("path_folded"),
771
+ parentId: integer("parent_id").references(() => files.id),
772
+ documentId: integer("document_id").references(() => documents.id),
773
+ mime: text("mime"),
774
+ size: integer("size"),
775
+ contentHash: text("content_hash"),
776
+ url: text("url"),
777
+ externalCreatedAt: integer("external_created_at"),
778
+ externalUpdatedAt: integer("external_updated_at"),
779
+ metadata: text("metadata"),
780
+ seenAt: integer("seen_at"),
781
+ createdAt: integer("created_at").notNull(),
782
+ updatedAt: integer("updated_at").notNull(),
783
+ deletedAt: integer("deleted_at"),
784
+ }, (table) => [
785
+ unique().on(table.accountId, table.externalId),
786
+ index("files_by_account_id_path_folded").on(table.accountId, table.pathFolded),
787
+ index("files_path_to_fold").on(table.id).where(sql `path_folded IS NULL`),
788
+ index("files_by_parent_id").on(table.parentId),
789
+ index("files_by_document_id").on(table.documentId),
790
+ ]);
755
791
  export const documentRevisions = sqliteTable("document_revisions", {
756
792
  id: integer("id").primaryKey(),
757
793
  documentId: integer("document_id")
@@ -817,6 +853,113 @@ export const memoryIndexPending = sqliteTable("memory_index_pending", {
817
853
  id: integer("id").notNull(),
818
854
  indexableType: text("indexable_type").notNull(),
819
855
  }, (table) => [primaryKey({ columns: [table.indexableType, table.id] })]);
856
+ export const calendars = sqliteTable("calendars", {
857
+ id: integer("id").primaryKey(),
858
+ accountId: integer("account_id")
859
+ .notNull()
860
+ .references(() => accounts.id),
861
+ externalId: text("external_id").notNull(),
862
+ title: text("title"),
863
+ timezone: text("timezone"),
864
+ accessRole: text("access_role"),
865
+ primaryCalendar: integer("primary_calendar").notNull(),
866
+ synced: integer("synced").notNull(),
867
+ seenAt: integer("seen_at"),
868
+ createdAt: integer("created_at").notNull(),
869
+ updatedAt: integer("updated_at").notNull(),
870
+ deletedAt: integer("deleted_at"),
871
+ }, (table) => [unique().on(table.accountId, table.externalId)]);
872
+ export const calendarEntries = sqliteTable("calendar_entries", {
873
+ id: integer("id").primaryKey(),
874
+ calendarId: integer("calendar_id")
875
+ .notNull()
876
+ .references(() => calendars.id),
877
+ kind: text("kind").notNull(),
878
+ seriesEntryId: integer("series_entry_id").references(() => calendarEntries.id),
879
+ eventId: integer("event_id").references(() => events.id),
880
+ eventSeriesId: integer("event_series_id").references(() => eventSeries.id),
881
+ externalId: text("external_id").notNull(),
882
+ icalUid: text("ical_uid"),
883
+ originalStartsAt: integer("original_starts_at"),
884
+ exception: integer("exception").notNull(),
885
+ status: text("status").notNull(),
886
+ title: text("title"),
887
+ description: text("description"),
888
+ location: text("location"),
889
+ startsAt: integer("starts_at"),
890
+ endsAt: integer("ends_at"),
891
+ allDay: integer("all_day").notNull(),
892
+ startDate: text("start_date"),
893
+ endDate: text("end_date"),
894
+ timezone: text("timezone"),
895
+ recurrence: text("recurrence"),
896
+ recurrenceEndsAt: integer("recurrence_ends_at"),
897
+ eventType: text("event_type"),
898
+ transparency: text("transparency"),
899
+ visibility: text("visibility"),
900
+ ownResponse: text("own_response"),
901
+ organizerIdentityId: integer("organizer_identity_id").references(() => identities.id),
902
+ joinUrl: text("join_url"),
903
+ joinKey: text("join_key"),
904
+ conference: text("conference"),
905
+ url: text("url"),
906
+ sequence: integer("sequence"),
907
+ etag: text("etag"),
908
+ externalUpdatedAt: integer("external_updated_at"),
909
+ metadata: text("metadata"),
910
+ seenAt: integer("seen_at"),
911
+ createdAt: integer("created_at").notNull(),
912
+ updatedAt: integer("updated_at").notNull(),
913
+ deletedAt: integer("deleted_at"),
914
+ }, (table) => [
915
+ unique().on(table.calendarId, table.externalId),
916
+ unique().on(table.seriesEntryId, table.originalStartsAt),
917
+ index("calendar_entries_by_calendar_id_starts_at").on(table.calendarId, table.startsAt),
918
+ index("calendar_entries_by_ical_uid").on(table.icalUid).where(sql `ical_uid IS NOT NULL`),
919
+ index("calendar_entries_by_join_key").on(table.joinKey).where(sql `join_key IS NOT NULL`),
920
+ index("calendar_entries_by_event_id").on(table.eventId),
921
+ index("calendar_entries_by_event_series_id").on(table.eventSeriesId),
922
+ index("calendar_entries_by_event_type_event_id").on(table.eventType, table.eventId),
923
+ index("calendar_entries_by_organizer_identity_id").on(table.organizerIdentityId),
924
+ ]);
925
+ export const calendarEntryRevisions = sqliteTable("calendar_entry_revisions", {
926
+ id: integer("id").primaryKey(),
927
+ entryId: integer("entry_id")
928
+ .notNull()
929
+ .references(() => calendarEntries.id),
930
+ title: text("title"),
931
+ startsAt: integer("starts_at"),
932
+ endsAt: integer("ends_at"),
933
+ location: text("location"),
934
+ status: text("status"),
935
+ recurrence: text("recurrence"),
936
+ recurrenceEndsAt: integer("recurrence_ends_at"),
937
+ externalUpdatedAt: integer("external_updated_at"),
938
+ createdAt: integer("created_at").notNull(),
939
+ }, (table) => [index("calendar_entry_revisions_by_entry_id_created_at").on(table.entryId, table.createdAt)]);
940
+ export const calendarEntryParticipants = sqliteTable("calendar_entry_participants", {
941
+ id: integer("id").primaryKey(),
942
+ entryId: integer("entry_id")
943
+ .notNull()
944
+ .references(() => calendarEntries.id),
945
+ identityId: integer("identity_id").references(() => identities.id),
946
+ email: text("email").notNull(),
947
+ name: text("name"),
948
+ response: text("response"),
949
+ optional: integer("optional").notNull(),
950
+ organizer: integer("organizer").notNull(),
951
+ self: integer("self").notNull(),
952
+ resource: integer("resource").notNull(),
953
+ createdAt: integer("created_at").notNull(),
954
+ updatedAt: integer("updated_at").notNull(),
955
+ }, (table) => [
956
+ unique().on(table.entryId, table.email),
957
+ index("calendar_entry_participants_by_identity_id").on(table.identityId),
958
+ ]);
959
+ export const eventIndexPending = sqliteTable("event_index_pending", {
960
+ id: integer("id").notNull(),
961
+ indexableType: text("indexable_type").notNull(),
962
+ }, (table) => [primaryKey({ columns: [table.indexableType, table.id] })]);
820
963
  export const eventSeries = sqliteTable("event_series", {
821
964
  id: integer("id").primaryKey(),
822
965
  title: text("title"),
@@ -838,6 +981,9 @@ export const events = sqliteTable("events", {
838
981
  createdAt: integer("created_at").notNull(),
839
982
  updatedAt: integer("updated_at").notNull(),
840
983
  deletedAt: integer("deleted_at"),
984
+ status: text("status"),
985
+ allDay: integer("all_day"),
986
+ sourceDeletedAt: integer("source_deleted_at"),
841
987
  }, (table) => [index("events_by_time").on(table.startsAt), index("events_by_event_series_id").on(table.eventSeriesId)]);
842
988
  export const meetingSeries = sqliteTable("meeting_series", {
843
989
  id: integer("id").primaryKey(),
@@ -883,10 +1029,12 @@ export const meetings = sqliteTable("meetings", {
883
1029
  createdAt: integer("created_at").notNull(),
884
1030
  updatedAt: integer("updated_at").notNull(),
885
1031
  deletedAt: integer("deleted_at"),
886
- /** `fold(title)`: SQLite's lower() folds ASCII only, so filters compare this. */
887
1032
  titleFolded: text("title_folded"),
1033
+ joinKey: text("join_key"),
888
1034
  }, (table) => [
889
1035
  unique().on(table.accountId, table.externalId),
1036
+ index("meetings_by_join_key").on(table.joinKey).where(sql `join_key IS NOT NULL`),
1037
+ index("meetings_join_key_to_fill").on(table.id).where(sql `join_url IS NOT NULL AND join_key IS NULL`),
890
1038
  index("meetings_by_time").on(table.accountId, desc(table.startedAt)),
891
1039
  index("meetings_by_account_id_title_folded").on(table.accountId, table.titleFolded),
892
1040
  index("meetings_title_to_fold").on(table.id).where(sql `title IS NOT NULL AND title_folded IS NULL`),
@@ -1252,26 +1400,23 @@ export const agentActions = sqliteTable("agent_actions", {
1252
1400
  index("agent_actions_by_actor_type_actor_id").on(table.actorType, table.actorId),
1253
1401
  index("agent_actions_by_target_type_target_id").on(table.targetType, table.targetId),
1254
1402
  ]);
1255
- export const tags = sqliteTable("tags", {
1256
- id: integer("id").primaryKey(),
1257
- name: text("name").notNull().unique(),
1258
- kind: text("kind").notNull().default("tag"),
1259
- createdAt: integer("created_at").notNull(),
1260
- updatedAt: integer("updated_at").notNull(),
1261
- });
1262
- export const autoTagClaims = sqliteTable("auto_tag_claims", {
1403
+ export { canonicalTags } from "./schema/canonical-tags.js";
1404
+ export const canonicalAutoTagClaims = sqliteTable("canonical_auto_tag_claims", {
1263
1405
  chatId: integer("chat_id")
1264
1406
  .notNull()
1265
1407
  .references(() => chats.id),
1266
1408
  tagId: integer("tag_id")
1267
1409
  .notNull()
1268
- .references(() => tags.id),
1410
+ .references(() => canonicalTags.id),
1269
1411
  algorithm: text("algorithm").notNull(),
1270
1412
  score: real("score").notNull(),
1271
1413
  fields: text("fields").notNull(),
1272
1414
  createdAt: integer("created_at").notNull(),
1273
1415
  updatedAt: integer("updated_at").notNull(),
1274
- }, (table) => [primaryKey({ columns: [table.chatId, table.tagId] }), index("auto_tag_claims_by_tag_id").on(table.tagId)]);
1416
+ }, (table) => [
1417
+ primaryKey({ columns: [table.chatId, table.tagId] }),
1418
+ index("canonical_auto_tag_claims_by_tag_id").on(table.tagId),
1419
+ ]);
1275
1420
  export const links = sqliteTable("links", {
1276
1421
  id: integer("id").primaryKey(),
1277
1422
  fromType: text("from_type").notNull(),
@@ -1320,7 +1465,7 @@ export const taggings = sqliteTable("taggings", {
1320
1465
  id: integer("id").primaryKey(),
1321
1466
  tagId: integer("tag_id")
1322
1467
  .notNull()
1323
- .references(() => tags.id),
1468
+ .references(() => canonicalTags.id),
1324
1469
  taggableType: text("taggable_type").notNull(),
1325
1470
  taggableId: integer("taggable_id").notNull(),
1326
1471
  main: integer("main").notNull().default(0),
@@ -1334,6 +1479,39 @@ export const taggings = sqliteTable("taggings", {
1334
1479
  uniqueIndex("taggings_main_topic").on(table.taggableType, table.taggableId).where(sql `main = 1`),
1335
1480
  index("taggings_by_author_type_author_id").on(table.authorType, table.authorId),
1336
1481
  ]);
1482
+ export { tagConsolidations, tagRedirects } from "./schema/tag-consolidations.js";
1483
+ export const entityLinkEvents = sqliteTable("entity_link_events", {
1484
+ id: integer("id").primaryKey(),
1485
+ linkableType: text("linkable_type").notNull(),
1486
+ linkableId: integer("linkable_id").notNull(),
1487
+ entityType: text("entity_type").notNull(),
1488
+ fromEntityId: integer("from_entity_id"),
1489
+ toEntityId: integer("to_entity_id"),
1490
+ method: text("method").notNull(),
1491
+ confidence: real("confidence"),
1492
+ author: text("author").notNull(),
1493
+ createdAt: integer("created_at").notNull(),
1494
+ }, (table) => [
1495
+ index("entity_link_events_by_linkable_type_linkable_id").on(table.linkableType, table.linkableId),
1496
+ index("entity_link_events_by_entity_type_to_entity_id").on(table.entityType, table.toEntityId),
1497
+ ]);
1498
+ export const linkReviews = sqliteTable("link_reviews", {
1499
+ id: integer("id").primaryKey(),
1500
+ linkableType: text("linkable_type").notNull(),
1501
+ linkableId: integer("linkable_id").notNull(),
1502
+ entityType: text("entity_type").notNull(),
1503
+ candidates: text("candidates").notNull(),
1504
+ reason: text("reason").notNull(),
1505
+ status: text("status").notNull(),
1506
+ chosenId: integer("chosen_id"),
1507
+ decidedBy: text("decided_by"),
1508
+ decidedAt: integer("decided_at"),
1509
+ createdAt: integer("created_at").notNull(),
1510
+ updatedAt: integer("updated_at").notNull(),
1511
+ }, (table) => [
1512
+ unique().on(table.linkableType, table.linkableId, table.entityType),
1513
+ index("link_reviews_by_status_created_at").on(table.status, table.createdAt),
1514
+ ]);
1337
1515
  export const conversations = sqliteTable("conversations", {
1338
1516
  id: integer("id").primaryKey(),
1339
1517
  chatId: integer("chat_id")
@@ -0,0 +1,3 @@
1
+ import type { TagConsolidationApplyOptions, TagConsolidationOptions, TagConsolidationPreview, TagConsolidationReceipt } from "../../domain/tag-consolidation.js";
2
+ import type { StoreContext } from "./open.js";
3
+ export declare const applyTagConsolidation: (context: StoreContext, preview: (options: TagConsolidationOptions) => TagConsolidationPreview, options: TagConsolidationApplyOptions) => TagConsolidationReceipt;
@@ -0,0 +1,88 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CliError } from "@wirecat/cli-base";
3
+ import { createTagRedirectIndex } from "../../domain/tag-redirects.js";
4
+ import { ownerPerson } from "./actors.js";
5
+ import { consolidationApplyInput } from "./tag-consolidation-input.js";
6
+ import { tagRedirectSnapshot } from "./tag-resolution.js";
7
+ import { write } from "./transaction.js";
8
+ const receiptOf = (row) => ({
9
+ id: String(row.id),
10
+ sourceId: String(row.source_id),
11
+ survivorId: String(row.survivor_id),
12
+ sourceMemberIds: JSON.parse(String(row.source_member_ids)),
13
+ survivorMemberIds: JSON.parse(String(row.survivor_member_ids)),
14
+ actor: { type: "person", id: String(row.actor_id) },
15
+ createdAt: new Date(Number(row.created_at)).toISOString(),
16
+ idempotencyKey: String(row.idempotency_key),
17
+ vocabularyFingerprint: String(row.vocabulary_fingerprint),
18
+ previewFingerprint: String(row.preview_fingerprint),
19
+ counts: JSON.parse(String(row.counts)),
20
+ });
21
+ export const applyTagConsolidation = (context, preview, options) => {
22
+ const { source, survivor, maxRows, maxReadBytes } = consolidationApplyInput(options);
23
+ const { database, now } = context;
24
+ const check = () => {
25
+ if (options.signal?.aborted)
26
+ throw new CliError("cancelled", "tag consolidation was cancelled");
27
+ };
28
+ check();
29
+ return write(database, () => {
30
+ const owner = ownerPerson(database);
31
+ const request = createHash("sha256")
32
+ .update(JSON.stringify([
33
+ source,
34
+ survivor,
35
+ owner.type,
36
+ owner.id,
37
+ options.expectedVocabularyFingerprint,
38
+ options.expectedPreviewFingerprint,
39
+ ]))
40
+ .digest("hex");
41
+ const held = database
42
+ .prepare("SELECT * FROM tag_consolidations WHERE idempotency_key=?")
43
+ .get(options.idempotencyKey);
44
+ if (held) {
45
+ if (held.request_fingerprint !== request)
46
+ throw new CliError("configuration_error", "idempotencyKey already records another reviewed tag consolidation");
47
+ return receiptOf(held);
48
+ }
49
+ const selected = preview({
50
+ sourceId: options.sourceId,
51
+ survivorId: options.survivorId,
52
+ expectedVocabularyFingerprint: options.expectedVocabularyFingerprint,
53
+ maxRows,
54
+ maxReadBytes,
55
+ sampleLimit: 1,
56
+ ...(options.signal ? { signal: options.signal } : {}),
57
+ });
58
+ if (selected.previewFingerprint !== options.expectedPreviewFingerprint)
59
+ throw new CliError("configuration_error", "tag dependencies changed since the consolidation preview", {
60
+ reason: "vocabulary.stale",
61
+ });
62
+ const graph = tagRedirectSnapshot(database);
63
+ createTagRedirectIndex({
64
+ ...graph,
65
+ redirects: [
66
+ ...graph.redirects.map((row) => row.survivorId === String(source) ? { ...row, survivorId: String(survivor) } : row),
67
+ { sourceId: String(source), survivorId: String(survivor) },
68
+ ],
69
+ });
70
+ const at = now();
71
+ const counts = {
72
+ assignments: selected.assignments.source + selected.assignments.survivor,
73
+ aliases: selected.aliases.source + selected.aliases.survivor,
74
+ automaticClaims: selected.automaticClaims.source + selected.automaticClaims.survivor,
75
+ links: selected.links.total,
76
+ };
77
+ check();
78
+ database.prepare("UPDATE tag_redirects SET survivor_id=? WHERE survivor_id=?").run(survivor, source);
79
+ database
80
+ .prepare("INSERT INTO tag_redirects(source_id,survivor_id,created_at) VALUES(?,?,?)")
81
+ .run(source, survivor, at);
82
+ tagRedirectSnapshot(database);
83
+ const row = database
84
+ .prepare("INSERT INTO tag_consolidations(source_id,survivor_id,source_member_ids,survivor_member_ids,actor_type,actor_id,created_at,idempotency_key,vocabulary_fingerprint,preview_fingerprint,counts,request_fingerprint) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) RETURNING *")
85
+ .get(source, survivor, JSON.stringify(selected.sourceMemberIds), JSON.stringify(selected.survivorMemberIds), owner.type, owner.id, at, options.idempotencyKey, selected.vocabularyFingerprint, selected.previewFingerprint, JSON.stringify(counts), request);
86
+ return receiptOf(row);
87
+ }, { beforeCommit: check });
88
+ };
@@ -0,0 +1,16 @@
1
+ import type { TagConsolidationApplyOptions, TagConsolidationOptions } from "../../domain/tag-consolidation.js";
2
+ export declare const consolidationId: (value: unknown, field: string) => number;
3
+ export declare const consolidationInput: (options: TagConsolidationOptions, apply?: boolean) => {
4
+ source: number;
5
+ survivor: number;
6
+ maxRows: number;
7
+ maxReadBytes: number;
8
+ limit: number;
9
+ };
10
+ export declare const consolidationApplyInput: (options: TagConsolidationApplyOptions) => {
11
+ source: number;
12
+ survivor: number;
13
+ maxRows: number;
14
+ maxReadBytes: number;
15
+ limit: number;
16
+ };
@@ -0,0 +1,52 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ export const consolidationId = (value, field) => {
3
+ if (typeof value !== "string" || !/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value)))
4
+ throw new CliError("validation_error", `${field} must be a positive canonical tag id`);
5
+ return Number(value);
6
+ };
7
+ const bounded = (value, max, field) => {
8
+ if (!Number.isSafeInteger(value) || value < 1 || value > max)
9
+ throw new CliError("validation_error", `${field} must be an integer from 1 to ${max}`);
10
+ return value;
11
+ };
12
+ export const consolidationInput = (options, apply = false) => {
13
+ if (!options || typeof options !== "object" || Array.isArray(options))
14
+ throw new CliError("validation_error", "consolidation options are required");
15
+ const allowed = new Set([
16
+ "sourceId",
17
+ "survivorId",
18
+ "expectedVocabularyFingerprint",
19
+ "maxRows",
20
+ "maxReadBytes",
21
+ "signal",
22
+ ...(apply ? ["by", "expectedPreviewFingerprint", "idempotencyKey"] : ["sampleLimit"]),
23
+ ]);
24
+ for (const field of Object.keys(options))
25
+ if (!allowed.has(field))
26
+ throw new CliError("validation_error", `unknown consolidation field: ${field}`);
27
+ const source = consolidationId(options.sourceId, "sourceId");
28
+ const survivor = consolidationId(options.survivorId, "survivorId");
29
+ if (source === survivor)
30
+ throw new CliError("validation_error", "sourceId and survivorId must differ");
31
+ if (typeof options.expectedVocabularyFingerprint !== "string" ||
32
+ !/^v1:all:all:[a-f0-9]{64}$/.test(options.expectedVocabularyFingerprint))
33
+ throw new CliError("validation_error", "expectedVocabularyFingerprint must come from a complete unfiltered vocabulary preview");
34
+ return {
35
+ source,
36
+ survivor,
37
+ maxRows: bounded(options.maxRows ?? 5000, 20000, "maxRows"),
38
+ maxReadBytes: bounded(options.maxReadBytes ?? 1024 * 1024, 16 * 1024 * 1024, "maxReadBytes"),
39
+ limit: apply ? 1 : bounded(options.sampleLimit ?? 10, 100, "sampleLimit"),
40
+ };
41
+ };
42
+ export const consolidationApplyInput = (options) => {
43
+ const input = consolidationInput(options, true);
44
+ if (options.by !== "owner")
45
+ throw new CliError("permission_error", "only the owner applies a reviewed tag consolidation");
46
+ if (typeof options.expectedPreviewFingerprint !== "string" ||
47
+ !/^tag-preview-v1:[a-f0-9]{64}$/.test(options.expectedPreviewFingerprint))
48
+ throw new CliError("validation_error", "expectedPreviewFingerprint must come from a tag consolidation preview");
49
+ if (typeof options.idempotencyKey !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(options.idempotencyKey))
50
+ throw new CliError("validation_error", "idempotencyKey must be 1–128 ASCII identifier characters");
51
+ return input;
52
+ };
@@ -1,77 +1,59 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { CliError } from "@wirecat/cli-base";
3
+ import { createTagRedirectIndex } from "../../domain/tag-redirects.js";
4
+ import { applyTagConsolidation } from "./tag-consolidation-apply.js";
5
+ import { consolidationInput } from "./tag-consolidation-input.js";
6
+ import { tagCanonicalId, tagRedirectSnapshot } from "./tag-resolution.js";
3
7
  import { read } from "./transaction.js";
4
8
  const selections = [
5
- ["taggings", "tag_id IN (?,?)"],
6
- ["aliases", "aliasable_type='tag' AND aliasable_id IN (?,?)"],
7
- ["auto_tag_claims", "tag_id IN (?,?)"],
8
- ["links", "(from_type='tag' AND from_id IN (?,?)) OR (to_type='tag' AND to_id IN (?,?))"],
9
+ ["taggings", "tag_id IN (SELECT value FROM json_each(?))"],
10
+ ["aliases", "aliasable_type='tag' AND aliasable_id IN (SELECT value FROM json_each(?))"],
11
+ ["canonical_auto_tag_claims", "tag_id IN (SELECT value FROM json_each(?))"],
12
+ [
13
+ "links",
14
+ "(from_type='tag' AND from_id IN (SELECT value FROM json_each(?))) OR (to_type='tag' AND to_id IN (SELECT value FROM json_each(?)))",
15
+ ],
9
16
  ];
10
- const bounded = (value, max, field) => {
11
- if (!Number.isSafeInteger(value) || value < 1 || value > max)
12
- throw new CliError("validation_error", `${field} must be an integer from 1 to ${max}`);
13
- return value;
14
- };
15
- const idOf = (value, field) => {
16
- if (typeof value !== "string" || !/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value)))
17
- throw new CliError("validation_error", `${field} must be a positive canonical tag id`);
18
- return Number(value);
19
- };
20
17
  const cancelled = (options) => {
21
18
  if (options.signal?.aborted)
22
19
  throw new CliError("cancelled", "tag consolidation preview was cancelled");
23
20
  };
24
21
  export const tagConsolidationStoreOver = (context, catalog) => ({
25
- previewTagConsolidation: async (options) => {
26
- if (!options || typeof options !== "object")
27
- throw new CliError("validation_error", "preview options are required");
28
- const allowed = new Set([
29
- "sourceId",
30
- "survivorId",
31
- "expectedVocabularyFingerprint",
32
- "maxRows",
33
- "maxReadBytes",
34
- "sampleLimit",
35
- "signal",
36
- ]);
37
- for (const field of Object.keys(options))
38
- if (!allowed.has(field))
39
- throw new CliError("validation_error", `unknown preview field: ${field}`);
40
- const source = idOf(options.sourceId, "sourceId");
41
- const survivor = idOf(options.survivorId, "survivorId");
42
- if (source === survivor)
43
- throw new CliError("validation_error", "sourceId and survivorId must differ");
44
- if (typeof options.expectedVocabularyFingerprint !== "string" ||
45
- !/^v1:all:all:[a-f0-9]{64}$/.test(options.expectedVocabularyFingerprint))
46
- throw new CliError("validation_error", "expectedVocabularyFingerprint must come from a complete unfiltered vocabulary preview");
47
- const maxRows = bounded(options.maxRows ?? 5000, 20000, "maxRows");
48
- const maxReadBytes = bounded(options.maxReadBytes ?? 1024 * 1024, 16 * 1024 * 1024, "maxReadBytes");
49
- const limit = bounded(options.sampleLimit ?? 10, 100, "sampleLimit");
50
- cancelled(options);
51
- // The catalog and dependency rows must describe one SQLite snapshot, including when called inside a write.
52
- return read(context.database, () => {
53
- const current = catalog();
54
- if (current.fingerprint !== options.expectedVocabularyFingerprint)
55
- throw new CliError("configuration_error", "vocabulary changed since the preview", {
56
- reason: "vocabulary.stale",
57
- });
58
- const selected = [source, survivor].map((id) => current.entries.find((entry) => entry.id === String(id) && entry.kind !== "project"));
59
- if (selected.some((entry) => !entry))
60
- throw new CliError("not_found", "a selected canonical tag is unavailable");
61
- if (selected.some((entry) => entry?.kind !== "tag"))
62
- throw new CliError("validation_error", "consolidation preview supports tags only");
63
- return previewRows(context, options, source, survivor, maxRows, maxReadBytes, limit, current);
64
- });
65
- },
22
+ previewTagConsolidation: async (options) => previewTagConsolidation(context, catalog, options),
23
+ applyTagConsolidation: async (options) => applyTagConsolidation(context, (input) => previewTagConsolidation(context, catalog, input), options),
66
24
  });
25
+ const previewTagConsolidation = (context, catalog, options) => {
26
+ const { source, survivor, maxRows, maxReadBytes, limit } = consolidationInput(options);
27
+ cancelled(options);
28
+ return read(context.database, () => {
29
+ const current = catalog();
30
+ if (current.fingerprint !== options.expectedVocabularyFingerprint)
31
+ throw new CliError("configuration_error", "vocabulary changed since the preview", { reason: "vocabulary.stale" });
32
+ if (tagCanonicalId(context.database, source) === tagCanonicalId(context.database, survivor))
33
+ throw new CliError("validation_error", "sourceId and survivorId already resolve to the same canonical tag");
34
+ const selected = [source, survivor].map((id) => current.entries.find((entry) => entry.id === String(id) && entry.kind !== "project"));
35
+ if (selected.some((entry) => !entry))
36
+ throw new CliError("not_found", "a selected canonical tag is unavailable");
37
+ if (selected.some((entry) => entry?.kind !== "tag"))
38
+ throw new CliError("validation_error", "consolidation preview supports tags only");
39
+ return previewRows(context, options, source, survivor, maxRows, maxReadBytes, limit, current);
40
+ });
41
+ };
67
42
  const previewRows = (context, options, source, survivor, maxRows, maxReadBytes, limit, catalog) => {
68
43
  const rows = new Map();
69
44
  let count = 0;
70
- let readBytes = 0;
71
- const hash = createHash("sha256").update(JSON.stringify([source, survivor, catalog.fingerprint]));
45
+ const graph = tagRedirectSnapshot(context.database);
46
+ const index = createTagRedirectIndex(graph);
47
+ const sourceMemberIds = index.resolve(String(source)).memberIds;
48
+ const survivorMemberIds = index.resolve(String(survivor)).memberIds;
49
+ const sourceMembers = new Set(sourceMemberIds.map(Number));
50
+ const memberIds = JSON.stringify([...sourceMemberIds, ...survivorMemberIds].map(Number));
51
+ const captured = JSON.stringify(graph);
52
+ let readBytes = Buffer.byteLength(captured);
53
+ const hash = createHash("sha256").update(JSON.stringify([source, survivor, catalog.fingerprint, graph]));
72
54
  for (const [table, where] of selections) {
73
55
  cancelled(options);
74
- const parameters = table === "links" ? [source, survivor, source, survivor] : [source, survivor];
56
+ const parameters = table === "links" ? [memberIds, memberIds] : [memberIds];
75
57
  const columns = context.database
76
58
  .prepare(`PRAGMA table_info(${table})`)
77
59
  .all()
@@ -87,7 +69,7 @@ const previewRows = (context, options, source, survivor, maxRows, maxReadBytes,
87
69
  if (count + size > maxRows || Number(totals?.bytes ?? 0) + readBytes > maxReadBytes)
88
70
  throw new CliError("validation_error", "tag dependencies exceed the preview budget; no partial preview was produced", { reason: "vocabulary.consolidation-over-budget", table });
89
71
  const values = context.database
90
- .prepare(`SELECT * FROM ${table} WHERE ${where} ORDER BY ${table === "auto_tag_claims" ? "chat_id,tag_id" : "id"} LIMIT ?`)
72
+ .prepare(`SELECT * FROM ${table} WHERE ${where} ORDER BY ${table === "canonical_auto_tag_claims" ? "chat_id,tag_id" : "id"} LIMIT ?`)
91
73
  .all(...parameters, maxRows + 1);
92
74
  const serialized = JSON.stringify(values);
93
75
  readBytes += Buffer.byteLength(serialized);
@@ -103,25 +85,26 @@ const previewRows = (context, options, source, survivor, maxRows, maxReadBytes,
103
85
  cancelled(options);
104
86
  const assignments = rows.get("taggings") ?? [];
105
87
  const aliases = rows.get("aliases") ?? [];
106
- const claims = rows.get("auto_tag_claims") ?? [];
88
+ const claims = rows.get("canonical_auto_tag_claims") ?? [];
107
89
  const links = rows.get("links") ?? [];
108
90
  const byTargetType = new Map();
109
91
  for (const row of assignments) {
110
92
  const type = String(row.taggable_type);
111
93
  const totals = byTargetType.get(type) ?? { type, source: 0, survivor: 0 };
112
- if (Number(row.tag_id) === source)
94
+ if (sourceMembers.has(Number(row.tag_id)))
113
95
  totals.source++;
114
96
  else
115
97
  totals.survivor++;
116
98
  byTargetType.set(type, totals);
117
99
  }
118
100
  const counts = (values, field) => ({
119
- source: values.filter((row) => Number(row[field]) === source).length,
120
- survivor: values.filter((row) => Number(row[field]) === survivor).length,
101
+ source: values.filter((row) => sourceMembers.has(Number(row[field]))).length,
102
+ survivor: values.filter((row) => !sourceMembers.has(Number(row[field]))).length,
121
103
  });
122
104
  const overlaps = (values, key) => {
123
- const targets = new Set(values.filter((row) => Number(row.tag_id) === source).map(key));
124
- return values.filter((row) => Number(row.tag_id) === survivor && targets.has(key(row))).length;
105
+ const targets = new Set(values.filter((row) => sourceMembers.has(Number(row.tag_id))).map(key));
106
+ return new Set(values.filter((row) => !sourceMembers.has(Number(row.tag_id)) && targets.has(key(row))).map(key))
107
+ .size;
125
108
  };
126
109
  const target = (id) => {
127
110
  const entry = catalog.entries.find((row) => row.kind === "tag" && row.id === String(id));
@@ -130,6 +113,8 @@ const previewRows = (context, options, source, survivor, maxRows, maxReadBytes,
130
113
  return {
131
114
  source: target(source),
132
115
  survivor: target(survivor),
116
+ sourceMemberIds,
117
+ survivorMemberIds,
133
118
  vocabularyFingerprint: catalog.fingerprint,
134
119
  previewFingerprint: `tag-preview-v1:${hash.digest("hex")}`,
135
120
  assignments: {
@@ -171,7 +156,7 @@ const previewRows = (context, options, source, survivor, maxRows, maxReadBytes,
171
156
  rows: count,
172
157
  readBytes,
173
158
  samplesComplete: [assignments, aliases, links].every((values) => values.length <= limit),
174
- applySupported: false,
159
+ applySupported: true,
175
160
  },
176
161
  };
177
162
  };