k0-board 0.5.0 → 0.6.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.
package/server/db.js CHANGED
@@ -390,7 +390,6 @@ db.exec(`
390
390
  `)
391
391
 
392
392
  splitSessionsOut()
393
- allocateMissingKeys()
394
393
 
395
394
  // Said out loud although `node:sqlite` already does it: unlike the sqlite3 command line, it opens
396
395
  // every connection with foreign keys ON, which is the only reason the cascades declared above are
@@ -657,62 +656,388 @@ function splitSessionsOut() {
657
656
  }
658
657
 
659
658
  /**
660
- * Every story gets its key. In `created_at, id` order per repository, so the numbers run in the
661
- * order the work was thought of rather than in whatever order the rows happen to come back.
659
+ * Every story gets its key, in `created_at, id` order, so the numbers run in the order the work
660
+ * was thought of rather than in whatever order the rows happen to come back.
662
661
  */
663
662
  function allocateMissingKeys() {
664
- const rows = db
665
- .prepare('SELECT id, project_path FROM story WHERE key_num = 0 ORDER BY project_path, created_at, id')
666
- .all()
663
+ const rows = db.prepare('SELECT id, project_path FROM story WHERE key_num = 0 ORDER BY created_at, id').all()
667
664
  for (const row of rows) {
668
665
  db.prepare('UPDATE story SET key_num = ? WHERE id = ?').run(allocateKey(row.project_path), row.id)
669
666
  }
670
667
  }
671
668
 
669
+ // ── What the cloud has not heard yet ─────────────────────────────────────────
670
+ // Every row the board is made of gets a `uid` — the name it goes by in the cloud and on every
671
+ // other machine, where its integer id means nothing — and every change to one of those rows
672
+ // leaves a line in `outbox` until the sync has delivered it.
673
+ //
674
+ // It is done with triggers, and that is the reason it can be trusted: forty-odd places in this
675
+ // file write to these tables, and a list of "remember to tell the cloud" calls would be forty
676
+ // chances to forget one. A trigger cannot be forgotten. Two switches in `sync_flag` decide whether
677
+ // it writes anything: `recording`, off until an account is linked so a board with no cloud does
678
+ // not grow a queue for ever, and `applying`, on while rows that came FROM the cloud are being
679
+ // written, so they are not sent straight back.
680
+
681
+ /** The tables the cloud keeps a copy of. Closed: nothing builds a table name out of a value. */
682
+ export const SYNCED = ['epic', 'story', 'session', 'decision', 'decision_check', 'round', 'check_item', 'dependency', 'story_log']
683
+
684
+ const UUID = `lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
685
+ substr(lower(hex(randomblob(2))), 2) || '-' || substr('89ab', 1 + (abs(random()) % 4), 1) ||
686
+ substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6)))`
687
+ const MS = `CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)`
688
+ const RECORDING = `(SELECT recording = 1 AND applying = 0 FROM sync_flag WHERE id = 1)`
689
+
690
+ function prepareSync() {
691
+ for (const t of SYNCED) addColumns(t, [['uid', 'TEXT']])
692
+ db.exec(`
693
+ CREATE TABLE IF NOT EXISTS outbox (
694
+ tbl TEXT NOT NULL,
695
+ row_id INTEGER NOT NULL,
696
+ uid TEXT,
697
+ op TEXT NOT NULL,
698
+ at INTEGER NOT NULL,
699
+ PRIMARY KEY (tbl, row_id)
700
+ );
701
+ CREATE TABLE IF NOT EXISTS sync_flag (
702
+ id INTEGER PRIMARY KEY CHECK (id = 1),
703
+ recording INTEGER NOT NULL DEFAULT 0,
704
+ applying INTEGER NOT NULL DEFAULT 0
705
+ );
706
+ INSERT OR IGNORE INTO sync_flag (id) VALUES (1);
707
+ -- A server that died in the middle of applying would otherwise stop recording for good.
708
+ UPDATE sync_flag SET applying = 0;
709
+ `)
710
+ for (const t of SYNCED) {
711
+ db.exec(`UPDATE ${t} SET uid = ${UUID} WHERE uid IS NULL`)
712
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${t}_uid ON ${t}(uid)`)
713
+ db.exec(`
714
+ CREATE TRIGGER IF NOT EXISTS sync_${t}_insert AFTER INSERT ON ${t} BEGIN
715
+ UPDATE ${t} SET uid = ${UUID} WHERE rowid = NEW.rowid AND uid IS NULL;
716
+ INSERT OR REPLACE INTO outbox (tbl, row_id, uid, op, at)
717
+ SELECT '${t}', NEW.rowid, (SELECT uid FROM ${t} WHERE rowid = NEW.rowid), 'upsert', ${MS}
718
+ WHERE ${RECORDING};
719
+ END;
720
+ CREATE TRIGGER IF NOT EXISTS sync_${t}_update AFTER UPDATE ON ${t} BEGIN
721
+ INSERT OR REPLACE INTO outbox (tbl, row_id, uid, op, at)
722
+ SELECT '${t}', NEW.rowid, NEW.uid, 'upsert', ${MS}
723
+ WHERE ${RECORDING} AND NEW.uid IS NOT NULL;
724
+ END;
725
+ CREATE TRIGGER IF NOT EXISTS sync_${t}_delete AFTER DELETE ON ${t} BEGIN
726
+ INSERT OR REPLACE INTO outbox (tbl, row_id, uid, op, at)
727
+ SELECT '${t}', OLD.rowid, OLD.uid, 'delete', ${MS}
728
+ WHERE ${RECORDING} AND OLD.uid IS NOT NULL;
729
+ END;
730
+ `)
731
+ }
732
+ }
733
+
734
+ // Here and not with the migrations above: it reads the constants just above it, which a call
735
+ // placed earlier in the file would reach before they exist.
736
+ prepareSync()
737
+
738
+ /** Whether there is anything on this board at all: a board with nothing on it has nothing to move. */
739
+ export function boardIsEmpty() {
740
+ return !db.prepare('SELECT 1 FROM story UNION ALL SELECT 1 FROM epic LIMIT 1').get()
741
+ }
742
+
672
743
  /**
673
- * The next number in this repository, and never one that has been handed out before — not after
674
- * a delete, not after a story moves under another epic. A repository k0 has not counted yet
675
- * starts above whatever is already there, so a database rebuilt from `.k0/` cannot collide with
676
- * the keys written in those files.
744
+ * A copy of the whole database at this instant, in one file. `VACUUM INTO` rather than copying
745
+ * `k0.db`: with the write-ahead log on, the file alone is not the board — the last few changes may
746
+ * still be sitting in `-wal` — and this writes one consistent file with all of it folded in.
677
747
  */
678
- // `except` is the story being moved into this repository, and it is already sitting in it by the
679
- // time the number is asked for: without leaving it out it counts itself, and the first story to
680
- // arrive in an empty repository is handed K3 instead of K1. Nothing is broken by the gap, but a
681
- // key is something the user reads out loud, and one that starts at three has to be explained.
682
- function allocateKey(projectPath, except = null) {
683
- const seq = db.prepare('SELECT next FROM key_seq WHERE project_path = ?').get(projectPath)
684
- const highest = db
685
- .prepare(
686
- `SELECT MAX(n) AS n FROM (
687
- SELECT MAX(key_num) AS n FROM story WHERE project_path = ? AND id IS NOT ?
688
- UNION ALL SELECT MAX(key_num) FROM epic WHERE project_path = ?
689
- )`
690
- )
691
- .get(projectPath, except)
692
- const next = Math.max(seq?.next ?? 1, (highest?.n ?? 0) + 1)
748
+ export function backupTo(file) {
749
+ db.prepare('VACUUM INTO ?').run(file)
750
+ return file
751
+ }
752
+
753
+ /** Whether changes are being written down for the cloud. */
754
+ export function syncRecording() {
755
+ return !!db.prepare('SELECT recording FROM sync_flag WHERE id = 1').get()?.recording
756
+ }
757
+
758
+ /**
759
+ * Start or stop writing changes down. Starting from nothing queues every row there is: the first
760
+ * sync after an account is linked sends the whole board, and after that only what moves.
761
+ */
762
+ export function setSyncRecording(on, { snapshot = false } = {}) {
763
+ atomically(() => {
764
+ db.prepare('UPDATE sync_flag SET recording = ? WHERE id = 1').run(on ? 1 : 0)
765
+ if (on && snapshot) {
766
+ for (const t of SYNCED) {
767
+ db.exec(`INSERT OR REPLACE INTO outbox (tbl, row_id, uid, op, at)
768
+ SELECT '${t}', rowid, uid, 'upsert', ${MS} FROM ${t} WHERE uid IS NOT NULL`)
769
+ }
770
+ }
771
+ if (!on) db.exec('DELETE FROM outbox')
772
+ })
773
+ }
774
+
775
+ /** The oldest changes not yet delivered. */
776
+ export function outboxBatch(limit = 200) {
777
+ return db.prepare('SELECT tbl, row_id, uid, op, at FROM outbox ORDER BY at, tbl, row_id LIMIT ?').all(limit)
778
+ }
779
+
780
+ export function outboxSize() {
781
+ return db.prepare('SELECT COUNT(*) AS n FROM outbox').get().n
782
+ }
783
+
784
+ /** Delivered. Only the line as it was read: a change made since then stays queued. */
785
+ export function outboxDone(entries) {
786
+ const del = db.prepare('DELETE FROM outbox WHERE tbl = ? AND row_id = ? AND at = ?')
787
+ for (const e of entries) del.run(e.tbl, e.row_id, e.at)
788
+ }
789
+
790
+ const syncedTable = (t) => (SYNCED.includes(t) ? t : null)
791
+
792
+ /** One synced row exactly as it is stored, or null when it is gone. */
793
+ export function syncRow(tbl, rowId) {
794
+ const t = syncedTable(tbl)
795
+ return t ? (db.prepare(`SELECT rowid AS _rowid, * FROM ${t} WHERE rowid = ?`).get(rowId) ?? null) : null
796
+ }
797
+
798
+ /** The uid of a row, from its id: how a reference leaves this machine. */
799
+ export function uidOf(tbl, id) {
800
+ const t = syncedTable(tbl)
801
+ if (!t || id == null) return null
802
+ return db.prepare(`SELECT uid FROM ${t} WHERE rowid = ?`).get(id)?.uid ?? null
803
+ }
804
+
805
+ /** The id of a row, from its uid: how a reference arrives on this machine. */
806
+ export function idOfUid(tbl, uid) {
807
+ const t = syncedTable(tbl)
808
+ if (!t || !uid) return null
809
+ return db.prepare(`SELECT rowid AS id FROM ${t} WHERE uid = ?`).get(uid)?.id ?? null
810
+ }
811
+
812
+ /**
813
+ * Writes rows that came from the cloud, without sending them back. `fn` gets `put(tbl, row)` and
814
+ * `drop(tbl, uid)`; `row` carries `uid` and plain columns, references already turned into ids.
815
+ * Columns this machine does not have are left out rather than failing the whole batch: a newer
816
+ * k0 on another machine may well know a column this one does not.
817
+ */
818
+ export function applyRemote(fn) {
819
+ const columns = new Map(SYNCED.map((t) => [t, new Set(db.prepare('SELECT name FROM pragma_table_info(?)').all(t).map((r) => r.name))]))
820
+ const put = (tbl, row) => {
821
+ const t = syncedTable(tbl)
822
+ if (!t || !row.uid) return null
823
+ const known = columns.get(t)
824
+ const fields = Object.entries(row).filter(([k]) => known.has(k) && k !== 'id' && k !== 'rowid')
825
+ const existing = idOfUid(t, row.uid)
826
+ if (existing != null) {
827
+ const set = fields.filter(([k]) => k !== 'uid')
828
+ if (set.length) db.prepare(`UPDATE ${t} SET ${set.map(([k]) => `"${k}" = ?`).join(', ')} WHERE rowid = ?`).run(...set.map(([, v]) => v), existing)
829
+ return existing
830
+ }
831
+ const { lastInsertRowid } = db
832
+ .prepare(`INSERT OR IGNORE INTO ${t} (${fields.map(([k]) => `"${k}"`).join(', ')}) VALUES (${fields.map(() => '?').join(', ')})`)
833
+ .run(...fields.map(([, v]) => v))
834
+ return Number(lastInsertRowid) || idOfUid(t, row.uid)
835
+ }
836
+ const drop = (tbl, uid) => {
837
+ const t = syncedTable(tbl)
838
+ if (t && uid) db.prepare(`DELETE FROM ${t} WHERE uid = ?`).run(uid)
839
+ }
840
+ db.exec('UPDATE sync_flag SET applying = 1')
841
+ try {
842
+ let out
843
+ atomically(() => {
844
+ out = fn(put, drop)
845
+ })
846
+ return out
847
+ } finally {
848
+ db.exec('UPDATE sync_flag SET applying = 0')
849
+ }
850
+ }
851
+
852
+ // ── Keys ─────────────────────────────────────────────────────────────────────
853
+ // A key is one number for the whole account, not one per repository: K150 is the same story
854
+ // whichever repository it lives in and whichever machine is looking at it. So the number that
855
+ // counts is the cloud's, which is the only place two machines can agree on what comes next.
856
+ //
857
+ // The cloud is asked by the server before it creates something (see `cloud.nextKey`), and the
858
+ // number arrives here as `cloud_key`. When it cannot be asked — no network, the cloud down — the
859
+ // story is still made, with a PROVISIONAL key: a negative number on disk, printed `K~3`, which
860
+ // the sync gives a real one the moment the cloud is back (`assignKey`). Nothing is held back or
861
+ // reserved in advance, so the numbers stay in the order the stories were made.
862
+ //
863
+ // Without an account linked — the tests, and somebody working on k0 itself with `K0_CLOUD=off` —
864
+ // there is no cloud to ask, and the account-wide counter is kept here instead: the row of
865
+ // `key_seq` whose `project_path` is empty.
866
+
867
+ /** What a key looks like to anybody reading it. One rule, used by every query that prints one. */
868
+ function keyText(col) {
869
+ return `CASE WHEN ${col} < 0 THEN 'K~' || (-${col}) ELSE 'K' || ${col} END`
870
+ }
871
+
872
+ const ACCOUNT = ''
873
+ const PROVISIONAL = '~provisional'
874
+
875
+ /** Whether keys come from the cloud. Set by `cloud.js` when an account is linked or unlinked. */
876
+ let cloudKeys = false
877
+ export function useCloudKeys(on) {
878
+ cloudKeys = !!on
879
+ }
880
+
881
+ function counter(name) {
882
+ return db.prepare('SELECT next FROM key_seq WHERE project_path = ?').get(name)?.next ?? 1
883
+ }
884
+
885
+ function setCounter(name, next) {
693
886
  db.prepare(
694
887
  `INSERT INTO key_seq (project_path, next) VALUES (?, ?)
695
888
  ON CONFLICT(project_path) DO UPDATE SET next = excluded.next`
696
- ).run(projectPath, next + 1)
889
+ ).run(name, next)
890
+ }
891
+
892
+ /** The highest real number anything carries, anywhere on this machine. */
893
+ function highestKey(except = null) {
894
+ return (
895
+ db
896
+ .prepare(
897
+ `SELECT MAX(n) AS n FROM (
898
+ SELECT MAX(key_num) AS n FROM story WHERE id IS NOT ?
899
+ UNION ALL SELECT MAX(key_num) FROM epic
900
+ )`
901
+ )
902
+ .get(except)?.n ?? 0
903
+ )
904
+ }
905
+
906
+ /**
907
+ * The next number, and never one handed out before — not after a delete either. With an account
908
+ * linked it is a provisional one, because a real one can only come from the cloud.
909
+ *
910
+ * `projectPath` is kept in the signature for the callers that still pass it: a number used to be
911
+ * per repository, and it is not any more.
912
+ */
913
+ function allocateKey(_projectPath = null, except = null) {
914
+ if (cloudKeys) return provisionalKey()
915
+ const next = Math.max(counter(ACCOUNT), highestKey(except) + 1)
916
+ setCounter(ACCOUNT, next + 1)
697
917
  return next
698
918
  }
699
919
 
920
+ function provisionalKey() {
921
+ const n = counter(PROVISIONAL)
922
+ setCounter(PROVISIONAL, n + 1)
923
+ return -n
924
+ }
925
+
700
926
  /**
701
- * A key that came from outside — a `.k0/` file read back into an empty database — keeps the
702
- * number it was written with, and the counter is pushed past it so nothing is ever handed it
703
- * a second time.
927
+ * A key that came from outside — the cloud, or a `.k0/` file read back into an empty database —
928
+ * keeps its number, and the local counter is pushed past it so nothing is ever handed it twice.
704
929
  */
705
- function reserveKey(projectPath, keyNum) {
930
+ function reserveKey(_projectPath, keyNum) {
706
931
  const n = Number(keyNum)
707
- if (!Number.isInteger(n) || n < 1) return allocateKey(projectPath)
708
- const seq = db.prepare('SELECT next FROM key_seq WHERE project_path = ?').get(projectPath)
709
- db.prepare(
710
- `INSERT INTO key_seq (project_path, next) VALUES (?, ?)
711
- ON CONFLICT(project_path) DO UPDATE SET next = excluded.next`
712
- ).run(projectPath, Math.max(seq?.next ?? 1, n + 1))
932
+ if (!Number.isInteger(n) || n < 1) return allocateKey()
933
+ setCounter(ACCOUNT, Math.max(counter(ACCOUNT), n + 1))
713
934
  return n
714
935
  }
715
936
 
937
+ /** Which key a new row is born with: the cloud's, a number it was told to keep, or the next one. */
938
+ const keyFor = (projectPath, cloudKey, keyNum) =>
939
+ Number.isInteger(cloudKey) && cloudKey > 0
940
+ ? reserveKey(projectPath, cloudKey)
941
+ : keyNum == null
942
+ ? allocateKey(projectPath)
943
+ : reserveKey(projectPath, keyNum)
944
+
945
+ /** The rows still carrying a provisional key, oldest first: the order the cloud numbers them in. */
946
+ export function provisionalKeys() {
947
+ return db
948
+ .prepare(
949
+ `SELECT 'story' AS kind, id, key_num, project_path, created_at FROM story WHERE key_num < 0
950
+ UNION ALL
951
+ SELECT 'epic', id, key_num, project_path, created_at FROM epic WHERE key_num < 0
952
+ ORDER BY created_at, id`
953
+ )
954
+ .all()
955
+ }
956
+
957
+ /** A provisional key becomes a real one. Returns the key text it had, so `.k0/` can let it go. */
958
+ export function assignKey(kind, id, keyNum) {
959
+ const table = kind === 'epic' ? 'epic' : kind === 'story' ? 'story' : null
960
+ const n = Number(keyNum)
961
+ if (!table || !Number.isInteger(n) || n < 1) return null
962
+ const row = db.prepare(`SELECT key_num FROM ${table} WHERE id = ?`).get(id)
963
+ if (!row || row.key_num > 0) return null
964
+ db.prepare(`UPDATE ${table} SET key_num = ? WHERE id = ?`).run(n, id)
965
+ setCounter(ACCOUNT, Math.max(counter(ACCOUNT), n + 1))
966
+ return `K~${-row.key_num}`
967
+ }
968
+
969
+ /**
970
+ * The one-time move to account-wide numbers: every story and epic on this machine, in the order
971
+ * it was made, numbered from `start`. Returns the old and the new key of each, which is what the
972
+ * person moving their board reads before saying yes. With `apply` false nothing is written.
973
+ */
974
+ export function renumberAll({ start = 1, apply = false } = {}) {
975
+ const rows = db
976
+ .prepare(
977
+ `SELECT 'story' AS kind, id, key_num, project_path, title, created_at FROM story
978
+ UNION ALL
979
+ SELECT 'epic', id, key_num, project_path, title, created_at FROM epic
980
+ ORDER BY created_at, kind, id`
981
+ )
982
+ .all()
983
+ const plan = rows.map((r, i) => ({
984
+ kind: r.kind,
985
+ id: r.id,
986
+ project_path: r.project_path,
987
+ title: r.title,
988
+ old: r.key_num < 0 ? `K~${-r.key_num}` : `K${r.key_num}`,
989
+ key_num: start + i,
990
+ new: `K${start + i}`,
991
+ }))
992
+ // The keys written INSIDE the text — "see K12", a plan naming the story it depends on, a prompt
993
+ // saying which epic it belongs to — are moved too, or every one of them would quietly point at
994
+ // a different story after the move. Only within the same repository: that is the only place a
995
+ // bare `K12` ever meant anything. One pass with the whole map, never one key after another, so a
996
+ // K3 that became K12 is not then turned into whatever K12 became.
997
+ const byRepo = new Map()
998
+ for (const p of plan) {
999
+ if (!byRepo.has(p.project_path)) byRepo.set(p.project_path, new Map())
1000
+ byRepo.get(p.project_path).set(p.old, p.new)
1001
+ }
1002
+ const rewrite = (repo, text) => {
1003
+ const map = byRepo.get(repo)
1004
+ if (!map || typeof text !== 'string' || !text) return text
1005
+ return text.replace(/(?<![\w~])K~?\d+(?![\w])/g, (k) => map.get(k) ?? k)
1006
+ }
1007
+ const TEXTS = [
1008
+ ['story', ['title', 'description', 'body', 'prompt', 'plan'], 'project_path'],
1009
+ ['epic', ['title', 'body'], 'project_path'],
1010
+ ['decision', ['text'], `COALESCE((SELECT project_path FROM story WHERE id = decision.story_id), (SELECT project_path FROM epic WHERE id = decision.epic_id))`],
1011
+ ['round', ['question', 'answer'], `COALESCE((SELECT project_path FROM story WHERE id = round.story_id), (SELECT project_path FROM epic WHERE id = round.epic_id))`],
1012
+ ['story_log', ['text'], '(SELECT project_path FROM story WHERE id = story_log.story_id)'],
1013
+ ['check_item', ['text', 'evidence'], '(SELECT project_path FROM story WHERE id = check_item.story_id)'],
1014
+ ]
1015
+ const changes = []
1016
+ for (const [table, cols, repoExpr] of TEXTS) {
1017
+ const rows = db.prepare(`SELECT id, ${repoExpr} AS repo, ${cols.join(', ')} FROM ${table}`).all()
1018
+ for (const r of rows) {
1019
+ const set = cols.filter((c) => rewrite(r.repo, r[c]) !== r[c])
1020
+ if (set.length) changes.push({ table, id: r.id, set: set.map((c) => [c, rewrite(r.repo, r[c])]) })
1021
+ }
1022
+ }
1023
+ plan.textChanges = changes.length
1024
+
1025
+ if (apply) {
1026
+ atomically(() => {
1027
+ for (const p of plan) db.prepare(`UPDATE ${p.kind} SET key_num = ? WHERE id = ?`).run(p.key_num, p.id)
1028
+ for (const c of changes) {
1029
+ db.prepare(`UPDATE ${c.table} SET ${c.set.map(([k]) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...c.set.map(([, v]) => v), c.id)
1030
+ }
1031
+ db.prepare('DELETE FROM key_seq').run()
1032
+ setCounter(ACCOUNT, start + plan.length)
1033
+ })
1034
+ }
1035
+ return plan
1036
+ }
1037
+
1038
+ // After the keys section and not with the migrations above it, for the same reason as `prepareSync`.
1039
+ allocateMissingKeys()
1040
+
716
1041
  const now = () => Date.now()
717
1042
 
718
1043
  // ── Stories ──────────────────────────────────────────────────────────────────
@@ -748,7 +1073,7 @@ const PATCHABLE = [
748
1073
  * the two axes are put back together here, in one place, and only here.
749
1074
  */
750
1075
  const STORY_COLUMNS = `
751
- s.id, s.key_num, 'K' || s.key_num AS "key", s.project_path, s.epic_id, s.parent_story_id,
1076
+ s.id, s.uid, s.key_num, ${keyText('s.key_num')} AS "key", s.project_path, s.epic_id, s.parent_story_id,
752
1077
  s.title, s.description, s.body, s.prompt, s.plan, s.state, s.starred, s.lang, s.color,
753
1078
  s.sort_hint, s.completed_at, s.imported_at, s.created_at, s.updated_at,
754
1079
  v.id AS session_row_id, v.session_id, COALESCE(v.alive, 0) AS session_alive,
@@ -881,10 +1206,11 @@ export function createStory({
881
1206
  parent_story_id = null,
882
1207
  sort_hint = 0,
883
1208
  key_num = null,
1209
+ cloud_key = null,
884
1210
  auto_send = false,
885
1211
  }) {
886
1212
  const t = now()
887
- const key = key_num == null ? allocateKey(project_path) : reserveKey(project_path, key_num)
1213
+ const key = keyFor(project_path, cloud_key, key_num)
888
1214
  const { lastInsertRowid } = q(`
889
1215
  INSERT INTO story (key_num, title, description, project_path, epic_id, parent_story_id, prompt, body,
890
1216
  color, lang, state, sort_hint, created_at, updated_at)
@@ -913,14 +1239,14 @@ export function sessionIds() {
913
1239
  * at the bottom of the post-it comes from: importing must not make everything look like today.
914
1240
  * The state is `Working` and not `Backlog` for the same reason: this is work that has started.
915
1241
  */
916
- export function importStory({ title, description = '', project_path, session_id, started_at, ended_at }) {
1242
+ export function importStory({ title, description = '', project_path, session_id, started_at, ended_at, cloud_key = null }) {
917
1243
  const t = now()
918
1244
  const born = Number(started_at) || t
919
1245
  const last = Number(ended_at) || born
920
1246
  const { lastInsertRowid } = q(`
921
1247
  INSERT INTO story (key_num, title, description, project_path, prompt, state, imported_at, created_at, updated_at)
922
1248
  VALUES (?, ?, ?, ?, '', 'Working', ?, ?, ?)
923
- `).run(allocateKey(project_path), title, description || '', project_path, t, born, last)
1249
+ `).run(keyFor(project_path, cloud_key, null), title, description || '', project_path, t, born, last)
924
1250
  const id = Number(lastInsertRowid)
925
1251
  q(`
926
1252
  INSERT INTO session (story_id, session_id, alive, status, started_at, ended_at) VALUES (?, ?, 0, 'IDLE', ?, ?)
@@ -951,8 +1277,6 @@ export function patchStory(id, fields) {
951
1277
  const wanted = { ...fields }
952
1278
  if (wanted.parent_story_id != null && wouldCycle(id, wanted.parent_story_id)) delete wanted.parent_story_id
953
1279
 
954
- const wasIn = q('SELECT project_path FROM story WHERE id = ?').get(id)?.project_path ?? null
955
-
956
1280
  const entries = Object.entries(wanted).filter(([k]) => PATCHABLE.includes(k))
957
1281
  if (entries.length) {
958
1282
  const set = entries.map(([k]) => `${k} = ?`).join(', ')
@@ -960,15 +1284,8 @@ export function patchStory(id, fields) {
960
1284
  .run(...entries.map(([, v]) => (typeof v === 'boolean' ? Number(v) : v)), now(), id)
961
1285
  }
962
1286
 
963
- // A key is a number inside one repository, so a story that moves to another repository cannot
964
- // keep it: the repository it lands in has a K3 of its own, and two of them make `getStoryByKey`
965
- // answer with whichever sorts first, while every skill that resolves a key addresses the wrong
966
- // story and `wt-K3` collides on the branch. It is given the next number where it now lives; the
967
- // one it left behind is not handed out again, because `allocateKey` only ever counts forwards.
968
- const nowIn = q('SELECT project_path FROM story WHERE id = ?').get(id)?.project_path ?? null
969
- if (nowIn && nowIn !== wasIn) {
970
- q('UPDATE story SET key_num = ? WHERE id = ?').run(allocateKey(nowIn, id), id)
971
- }
1287
+ // A key is one number for the whole account, so a story that moves to another repository keeps
1288
+ // it: there is no K3 of the other repository's for it to collide with any more.
972
1289
 
973
1290
  // Two fields that are not columns of `story` any more, and must not silently go nowhere:
974
1291
  // `state` writes a line in the diary, `auto_send` belongs to the session that will run next.
@@ -1318,7 +1635,7 @@ const EPIC_PATCHABLE = ['title', 'body', 'state', 'lang', 'sort_hint']
1318
1635
  /** An epic is open or it is done. There is no third thing an epic can be. */
1319
1636
  export const EPIC_STATES = ['Open', 'Done']
1320
1637
 
1321
- const epicRow = `SELECT id, key_num, 'K' || key_num AS "key", project_path, title, body, state, lang,
1638
+ const epicRow = `SELECT id, uid, key_num, ${keyText('key_num')} AS "key", project_path, title, body, state, lang,
1322
1639
  sort_hint, created_at, updated_at FROM epic`
1323
1640
 
1324
1641
  export function listEpics(projectPath = null) {
@@ -1335,9 +1652,9 @@ export function getEpicByKey(projectPath, keyNum) {
1335
1652
  return q(`${epicRow} WHERE project_path = ? AND key_num = ?`).get(projectPath, Number(keyNum))
1336
1653
  }
1337
1654
 
1338
- export function createEpic({ project_path, title, body = '', lang = '', sort_hint = 0, key_num = null }) {
1655
+ export function createEpic({ project_path, title, body = '', lang = '', sort_hint = 0, key_num = null, cloud_key = null }) {
1339
1656
  const t = now()
1340
- const key = key_num == null ? allocateKey(project_path) : reserveKey(project_path, key_num)
1657
+ const key = keyFor(project_path, cloud_key, key_num)
1341
1658
  const { lastInsertRowid } = q(`
1342
1659
  INSERT INTO epic (key_num, project_path, title, body, lang, sort_hint, created_at, updated_at)
1343
1660
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -1707,7 +2024,7 @@ export function patchCheckItem(id, fields) {
1707
2024
  /** The stories this one is waiting for, with enough of each to say so on the post-it. */
1708
2025
  export function dependenciesOf(storyId) {
1709
2026
  return q(`
1710
- SELECT s.id, s.key_num, 'K' || s.key_num AS "key", s.title, s.state
2027
+ SELECT s.id, s.key_num, ${keyText('s.key_num')} AS "key", s.title, s.state
1711
2028
  FROM dependency d JOIN story s ON s.id = d.depends_on
1712
2029
  WHERE d.story_id = ? ORDER BY s.sort_hint, s.id
1713
2030
  `).all(storyId)
@@ -1725,7 +2042,7 @@ export function dependenciesOf(storyId) {
1725
2042
  */
1726
2043
  export function dependenciesByStory() {
1727
2044
  const rows = q(`
1728
- SELECT d.story_id, s.id, 'K' || s.key_num AS "key", s.title, s.state
2045
+ SELECT d.story_id, s.id, ${keyText('s.key_num')} AS "key", s.title, s.state
1729
2046
  FROM dependency d JOIN story s ON s.id = d.depends_on
1730
2047
  ORDER BY s.sort_hint, s.id
1731
2048
  `).all()
@@ -1741,7 +2058,7 @@ export function dependenciesByStory() {
1741
2058
  /** And the ones waiting for it, which is what makes finishing this one worth doing first. */
1742
2059
  export function dependentsOf(storyId) {
1743
2060
  return q(`
1744
- SELECT s.id, s.key_num, 'K' || s.key_num AS "key", s.title, s.state
2061
+ SELECT s.id, s.key_num, ${keyText('s.key_num')} AS "key", s.title, s.state
1745
2062
  FROM dependency d JOIN story s ON s.id = d.story_id
1746
2063
  WHERE d.depends_on = ? ORDER BY s.sort_hint, s.id
1747
2064
  `).all(storyId)
@@ -1963,7 +2280,7 @@ export function usageAccounts() {
1963
2280
  export function usageSessions(from, to) {
1964
2281
  return q(`
1965
2282
  SELECT us.session_id, us.project_path, us.cwd, us.title, us.first_at, us.last_at,
1966
- st.id AS story_id, 'K' || st.key_num AS story_key, st.title AS story_title
2283
+ st.id AS story_id, ${keyText('st.key_num')} AS story_key, st.title AS story_title
1967
2284
  FROM usage_session us
1968
2285
  LEFT JOIN session se ON se.id = (
1969
2286
  SELECT id FROM session WHERE session_id = us.session_id ORDER BY started_at DESC LIMIT 1)