k0-board 0.5.0 → 0.7.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/.claude/skills/k0-ultracode/SKILL.md +3 -3
- package/CHANGELOG.md +95 -1
- package/LICENSE +83 -18
- package/README.md +259 -45
- package/bin/k0-board.js +48 -1
- package/package.json +2 -2
- package/platform/darwin/terminal.js +6 -7
- package/platform/shared/tmux.js +2 -2
- package/platform/win32/terminal.js +2 -2
- package/server/cloud.js +355 -0
- package/server/db.js +375 -58
- package/server/index.js +92 -2
- package/server/launcher.js +8 -10
- package/server/mirror.js +21 -0
- package/server/sync.js +383 -0
- package/server/update.js +3 -2
- package/web/account.js +265 -0
- package/web/base.css +93 -0
- package/web/board.js +1 -1
- package/web/files.js +1 -1
- package/web/index.html +1 -0
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
|
|
661
|
-
*
|
|
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
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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(
|
|
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 —
|
|
702
|
-
*
|
|
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(
|
|
930
|
+
function reserveKey(_projectPath, keyNum) {
|
|
706
931
|
const n = Number(keyNum)
|
|
707
|
-
if (!Number.isInteger(n) || n < 1) return allocateKey(
|
|
708
|
-
|
|
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, '
|
|
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 =
|
|
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(
|
|
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
|
|
964
|
-
//
|
|
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, '
|
|
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 =
|
|
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, '
|
|
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, '
|
|
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, '
|
|
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, '
|
|
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)
|