wallet-kit / src / ardegazu / wallet / ledger.cljs
   1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
;; The wallet's ledger: settled receipts, bank declines, pending orders, and a
;; balance that is a FOLD over receipts and nothing else.
;;
;; Storage is one localStorage key, `wal:<idPub>` — namespaced by IDENTITY, not
;; by app. This is the one thing in the suite that deliberately breaks the
;; per-app `ns:` convention the social layer uses: money follows the person, so
;; the game that just took a stake and the hub that shows the balance read the
;; same ledger, and neither owns it.
;;
;; ============================ NO RETENTION CAPS =============================
;; Nothing here caps, evicts, ages out or drop-oldests anything. There is no
;; receipt cap, no ledger cap, no ring buffer — deliberately, and it is a SUITE
;; RULE, not a local preference. social-kit's ReceiptSet keeps 300 leaderboard
;; receipts and forgets the rest, which is correct for a scoreboard and would be
;; catastrophic here: a receipt is the ONLY evidence that money moved, the
;; balance is a fold over all of them, and an evicted receipt is not a forgotten
;; game, it is money that silently ceases to exist. History is unbounded by
;; default. Pruning is an explicit user action and nothing else — `prune` takes
;; the user's own predicate, and no code path in this kit calls it.
;;
;; The failure mode this creates is a full quota, and it is handled by SAYING
;; SO, never by discarding: a write that throws (QuotaExceededError, private
;; mode, a disabled store) leaves the in-memory ledger intact and correct for
;; the session, sets `storageError`, and calls `onStorageError`. A consumer that
;; ignores both gets a session that works and a warning it chose not to read —
;; which is the right trade against losing a receipt.
;; ============================================================================
;;
;; ========================= ONE LEDGER, MANY TABS ============================
;; The key is the IDENTITY, so two tabs of two different apps are two
;; LedgerStores over one blob, and that is the NORMAL case here, not an edge:
;; the hub shows the balance while the game takes the stake. A store that
;; serialized its own memory and setItem'd it would therefore be a
;; last-writer-wins race whose loser is a receipt — the same "money that
;; silently ceases to exist" the retention rule above exists to forbid, arriving
;; by a different door and with `storageError` still null.
;;
;; So every write is a READ-MODIFY-WRITE. `persist!` re-reads what is on disk,
;; re-shapes it, UNIONS it into memory and writes the union. Union-merge is
;; sound here and nowhere else in the suite for one reason: every record is
;; keyed by a digest OF ITS OWN CONTENT — orderId over canon(order minus sig),
;; receiptKey over canon({v,t,bank,oid}) — so two tabs that hold the same record
;; hold the same key, and two records with the same key ARE the same record.
;; There is no field to reconcile and no ordering to agree on.
;;
;; A READ-MODIFY-WRITE IS NOT ATOMIC BY ITSELF, and saying "we merge" is not the
;; same as saying "nothing is lost". Between the getItem and the setItem sits a
;; parse, a re-shape, a merge, a reindex and a stringify, and a sibling that
;; completes its own write inside that window is simply overwritten — the exact
;; loss the merge exists to prevent, with `storageError` still null. An earlier
;; draft of this header claimed the merge closed that window. It narrows it; it
;; does not close it. What closes it is below, and this file now says what it
;; actually guarantees rather than what would be nice.
;;
;; THE SERIALIZATION IS A COMPARE-AND-SWAP, not a mutex. `persist!` loops:
;;
;;   1. read the raw bytes, remember them, parse/absorb/reindex/serialize;
;;   2. re-read the raw bytes IMMEDIATELY BEFORE writing. Different from step
;;      1 means a writer landed in the window, so throw the attempt away and
;;      start over from what they wrote — nothing is merged from a stale read;
;;   3. setItem;
;;   4. read back. Not our bytes means someone wrote over us between 3 and 4,
;;      so go round again and re-merge, which restores whatever they dropped.
;;
;; Bounded at MAX-CAS-TRIES attempts, after which the last write stands. What
;; this guarantees, exactly: NO WRITER'S RECORDS ARE LOST TO A MERGE TAKEN FROM
;; A STALE READ, and a clobbered write is detected and repaired on the next
;; pass. What it does not guarantee: true mutual exclusion. The residue is the
;; interval between the verifying getItem in step 2 and the setItem in step 3 —
;; two adjacent statements with no yield point between them, so no other agent
;; in this document can run there at all, and only a genuinely concurrent agent
;; (a second tab in a second process) can. That interval is as small as this API
;; permits: localStorage is synchronous and has no CAS of its own.
;;
;; `navigator.locks` was the obvious instrument and is deliberately NOT used.
;; It is async-only, `prune` is synchronous by contract, and a lock that one
;; writer skips protects nothing — the CAS would still have to carry the
;; guarantee, so the lock would be decoration over the mechanism that actually
;; works, on an API not every target has. One mechanism, everywhere, is the
;; smaller claim and the true one.
;;
;; After a merge the in-memory state is exactly what a fresh `load()` would
;; produce, because both go through the same three steps: parse, absorb,
;; reindex. Everything derived — which orders are answered, which (payer, bank,
;; seq) slots are taken, the sequence high-water marks — is rebuilt in
;; `reindex!` from the records rather than mutated in place, so there is one
;; definition of "consistent" and both paths reach it.
;;
;; TWO STORES IN ONE DOCUMENT ARE NOT TWO TABS. The DOM `storage` event never
;; fires on the window that wrote, so the listener below cannot reach a sibling
;; store in the SAME document — and a hub that renders the balance beside a game
;; that takes the stake is exactly that. Without help, `S1.prune()` followed by
;; `S2.addOrder()` puts the pruned record straight back, for ever. So stores
;; register in a per-key in-process REGISTRY and notify their same-document
;; siblings after every successful write; the registry is what `close()`
;; releases, alongside the listener.
;;
;; A notified sibling REFRESHES rather than reloading when it is holding records
;; disk has never seen (a failed write): a reload replaces memory with disk, and
;; memory is the only copy of a receipt whose write failed. Clean stores reload,
;; dirty ones union.
;;
;; The one thing a union cannot express is a DELETION, and there is exactly one:
;; `prune`. That call passes the keys it removed to `persist!` as a drop set, so
;; the disk copies do not come straight back. It is a LOCAL deletion — another
;; tab still holding the record in memory would restore it on its next write —
;; which is what the storage listener and the registry are for: that store
;; converges first, and a converged store no longer holds the record.
;; ============================================================================
;;
;; ===================== A WRITE THAT DID NOT HAPPEN SAYS SO ==================
;; The mutators used to return `true` for "recorded" whether or not a single
;; byte reached disk — under `unreadable` they returned `true` with nothing
;; written at all — so a caller could not tell a REJECTION from a BLOCKED write.
;; They now return a small integer:
;;
;;   0  rejected. Malformed, a duplicate, an order the bank has answered, or a
;;      (payer, bank, seq) slot already taken. Nothing changed.
;;   1  recorded AND on disk.
;;   2  recorded in memory, NOT on disk. `storageError` says why. The session is
;;      correct and the fold is right; only durability is missing.
;;
;; Chosen over a string union or an object because falsiness has to keep meaning
;; what it meant: `if (await store.addOrder(o))` is still exactly "was it
;; recorded", since 0 is the only falsy member. `=== true` stops matching, which
;; is a LOUD break on a success path rather than a silent one — an object would
;; have made every rejection truthy.
;;
;; `reserveSeq` is stricter still: it REJECTS when the allocation did not reach
;; disk. A sequence slot whose allocation was not persisted is a slot that will
;; be handed out again, and two signed orders on one slot is the precise failure
;; `seq` exists to prevent. There is no honest number to return.
;; ============================================================================
;;
;; ====================== UNREADABLE BYTES ARE NOT EMPTY ======================
;; Storage that will not parse, or carries a version this build does not know,
;; is NOT a fresh ledger. Treating it as one used to be silent twice over: no
;; error was signalled, and the next mutation overwrote the original bytes —
;; including the reserved `stakes` section a newer version would have written.
;; Now the read path matches the write path: `storageError` is set,
;; `onStorageError` fires, and `persist!` REFUSES to write until the consumer
;; has seen the bytes (`store.unreadable`) and called `acknowledgeUnreadable()`.
;; The session still works; only durability is on hold.
;; ============================================================================
;;
;; Two things the API refuses to blur:
;;
;;   PENDING IS NOT SETTLED. `balances()` reports {settled, held, available}.
;;   `settled` folds receipts. `held` is what outgoing pending orders have
;;   committed. `available` is the difference. An order is a signed promise in
;;   flight; nothing in it moves a balance.
;;
;;   THE STORE DOES NOT VERIFY SIGNATURES — except on import. addOrder,
;;   applyReceipt and applyDecline re-check STRUCTURE (they never trust
;;   localStorage or a caller's object shape) but assume the artifact already
;;   came through verifyOrder / verifySettlement, exactly as social-kit's
;;   ReceiptSet.add assumes verifyReceipt. `importJson` is the exception: an
;;   exported file is untrusted input, so every signature in it is checked AND
;;   the blob has to be this identity's own.
;;
;; The three mutators return PROMISES, and so do `reserveSeq` and `importJson`.
;; Their dedup keys are SHA-256 digests and SubtleCrypto is async; there is no
;; synchronous version of that to have. The readers — balances, receipts,
;; declines, pendingOrders, expiredOrders, nextSeq, exportJson — are all
;; synchronous, because the keys are persisted beside their records and
;; re-indexed on load.
;;
;; Every mutator is TOTAL: `.catch` is attached to the whole `awaits` ladder, as
;; the macro's docstring asks, so a hostile artifact off the network resolves
;; `0` rather than becoming an unhandled rejection in a caller that was promised
;; a number. `reserveSeq` is the deliberate exception and rejects — see below.
;;
;; `balances()` CANNOT BE JSON.stringify'd. The `*Exact` fields are BigInt and
;; `JSON.stringify` throws TypeError on one, by specification and with no option
;; to change it. That is a property of the answer being exact, not an oversight:
;; the whole point of those fields is that they are values no double can hold,
;; and a serializer whose number type IS a double has nothing to write. Convert
;; at the boundary — `String(b.settledExact)` for anything durable, `b.settled`
;; for a UI that has already checked `b.exact`. `exportJson` and the persisted
;; blob are unaffected: both carry the receipts, and the fold is redone on the
;; other side.
;;
;; JS objects only, no ClojureScript collections: same +19 KB cliff social-kit
;; measured, and every record here is handed to app code and a hand-authored
;; .d.ts anyway.
(ns ardegazu.wallet.ledger
  (:require [ardegazu.wallet.consts :as consts]
            [ardegazu.wallet.shapes :as shapes]
            [shadow.cljs.modern :refer (defclass)])
  (:require-macros [ardegazu.wallet.macros :refer [awaits obj oget truthy?]]))

(declare store-load! attach-storage-listener! register! refresh!)

;; ---- storage envelope ---------------------------------------------------------
;;
;; {"v":1,
;;  "receipts":[{"k":<receiptKey>,"o":<orderId>,"a":<wrc>}, …],
;;  "declines":[{"k":<receiptKey>,"o":<orderId>,"a":<wrj>}, …],
;;  "pending" :[{"k":<orderId>,             "a":<wpo>}, …],
;;  "issuance":[{"k":<issuanceKey>,         "a":<wri>}, …],
;;  "seqs"    :{"<bankerIdPub>":<highest sequence slot this wallet has taken>},
;;  "stakes"  :[]}
;;
;; `issuance` is ADDITIVE and `v` stays 1: a build that predates it reads the
;; rest of the blob exactly as before and carries the section through its own
;; writes as an unknown one — inside the EXTRA-MAX-BYTES courtesy budget below,
;; which is the one cost of not bumping `v` (an old writer sharing the key with
;; a very large issuance history would stop carrying it). Bumping `v` would
;; make the whole blob unreadable to every deployed wallet instead.
;;
;; The keys are stored beside their records rather than recomputed, which is
;; what keeps `load` synchronous — see this file's header. They are re-validated
;; as digests on the way in, and every record is re-shaped, so a corrupted or
;; hand-edited store degrades to FEWER RECORDS.
;;
;; Fewer records is not a smaller version of the truth, and an earlier draft of
;; this comment said "never a wrong balance", which was wrong. The balance is a
;; FOLD; dropping a receipt drops a term, so the total is wrong by that term,
;; and dropping the only receipt in a currency deletes the whole row — `settled`
;; for that currency goes from a number to `undefined`, with `storageError`
;; null. What the re-shape actually buys is that nothing is ever folded that no
;; verify* would have produced: a corrupt store cannot INVENT money or move a
;; balance in the attacker's favour. It can lose some. `exportJson` before
;; editing anything, and treat a row that vanished as evidence, not as zero.
;;
;; The `seqs` map is the exception, and deliberately: a dropped record loses
;; money, but a dropped HIGH-WATER MARK re-offers a spent sequence slot, and a
;; re-offered slot is two signed orders the payer can neither reconcile nor
;; cancel. So an unreadable `seqs` entry is not dropped — it makes the whole
;; blob UNREADABLE, exactly like bad JSON, and writing stops until the consumer
;; has seen the bytes.
;;
;; `seqs` is the sequence high-water mark per bank, raised by every order this
;; wallet records AND by `reserveSeq`. It is what makes an allocation survive a
;; reload, a second tab and a `prune` — a spent slot is never re-offered. It
;; rides the export too: without it a device that imports a pruned ledger offers
;; slot 1 for a slot the exporting device already spent.
;;
;; `stakes` is reserved for the stake-agreement layer that ships next. This
;; package never reads its contents and carries it through every write verbatim,
;; so an older wallet cannot destroy a newer one's data. Any OTHER top-level key
;; is carried through the same way, for the same reason, without needing this
;; version to have heard of it — but forward compatibility is a COURTESY WITH A
;; BUDGET, not an immortality guarantee:
;;
;;   * what is carried is what disk HAS. A section deleted on disk stays
;;     deleted; the old code accumulated keys and could only ever restore one,
;;     which made a junk section unremovable by any means;
;;   * the unknown sections together may occupy at most EXTRA-MAX-BYTES. Past
;;     that they are not carried, which means our next write drops them. A
;;     ledger cannot let an opaque blob it can neither read nor validate eat the
;;     quota its receipts need — that is a permanent denial of service on the
;;     money layer, and a receipt is worth more than a section this build has
;;     never heard of. A future version that needs more room than that ships its
;;     own key here and stops being unknown.

(def ^:private KNOWN-SECTIONS #js ["v" "receipts" "declines" "pending" "issuance" "seqs" "stakes"])

(defn- known-section? [k] (>= (.indexOf KNOWN-SECTIONS k) 0))

;; How much of the blob this version may spend carrying sections it cannot read.
;; 64 KiB of JSON is orders of magnitude more than a version marker or a small
;; reserved section needs and a small fraction of a 5 MB origin quota, which is
;; the trade: generous to a future version, bounded against a section that would
;; otherwise crowd out receipts for ever. See the envelope note above.
(def ^:private EXTRA-MAX-BYTES 65536)

;; How many times `persist!` will re-run its read-modify-write when the stored
;; bytes moved under it. Each retry is a fresh merge from what the other writer
;; actually left, so retrying is progress and not a spin: contention has to be
;; genuinely concurrent to happen once, and eight times in a row is not a case
;; worth an unbounded loop in a money path.
(def ^:private MAX-CAS-TRIES 8)

(defn- js-object?
  "A non-null, non-array JavaScript object. Local copy of shapes' predicate —
  three lines, and importing a private is not a thing."
  [v]
  (and (some? v)
       (identical? (js* "typeof ~{}" v) "object")
       (not (js/Array.isArray v))))

(defn- digest? [s] (and (string? s) (.test consts/DIGEST-RE s)))

;; ---- reading one stored blob --------------------------------------------------

(defn- read-settlements!
  "Load one settlement section. A record is kept only when its stored key, its
  stored order id AND its artifact all survive re-checking — which is what keeps
  `_oidOf` total, so a later write can always put `o` back.

  `o` IS TAKEN ON TRUST, checked as a digest and not recomputed, and that is a
  deliberate trade rather than an oversight. Recomputing it means hashing, which
  is async, which would make `load` async — and every reader in this file is
  synchronous precisely because the keys are stored beside their records. What a
  wrong `o` buys an attacker is: an order id that is not the id of the order
  beside it, so a settled order might be re-addable as pending, or a live one
  suppressed. What it costs the attacker is WRITE ACCESS TO THIS ORIGIN'S
  localStorage — at which point they can write any record they like, including
  a self-consistent one, and nothing recomputed here would notice. The check
  adds no capability the attacker does not already have."
  [raw field want-t out oid-of]
  (let [arr (oget raw field)]
    (when (js/Array.isArray arr)
      (dotimes [i (.-length ^js arr)]
        (let [e (aget arr i)
              k (when (some? e) (oget e "k"))
              o (when (some? e) (oget e "o"))
              ;; NO CLOCK (see shapes.cljs). These artifacts were admitted by a
              ;; verify* when they entered the ledger; re-reading them is a
              ;; question about STRUCTURE, and answering it against the wall
              ;; clock would make a fold depend on when it was loaded — a device
              ;; whose clock jumped backwards would silently drop its own
              ;; history. Clock-free keeps every ts-relative bound.
              a (when (some? e) (shapes/settlement-shape (oget e "a") nil))]
          (when (and (digest? k) (digest? o) (some? a)
                     (identical? (oget a "t") want-t))
            (.set ^js out k a)
            (.set ^js oid-of k o)))))))

(defn- read-pending! [raw out]
  (let [arr (oget raw "pending")]
    (when (js/Array.isArray arr)
      (dotimes [i (.-length ^js arr)]
        (let [e (aget arr i)
              k (when (some? e) (oget e "k"))
              a (when (some? e) (shapes/order-shape (oget e "a") nil))]
          (when (and (digest? k) (some? a))
            (.set ^js out k a)))))))

(defn- read-issuance!
  "Load the issuance receipts — read-pending!'s shape over issuance-shape,
  clock-free for the same reason: these were admitted by verify* when they
  entered, and re-reading them is a question about structure. An ABSENT section
  is fine: every blob written before wri existed has none."
  [raw out]
  (let [arr (oget raw "issuance")]
    (when (js/Array.isArray arr)
      (dotimes [i (.-length ^js arr)]
        (let [e (aget arr i)
              k (when (some? e) (oget e "k"))
              a (when (some? e) (shapes/issuance-shape (oget e "a") nil))]
          (when (and (digest? k) (some? a))
            (.set ^js out k a)))))))

(defn- seq-mark?
  "A banker id (or the neutral banker) against a slot number inside the same
  exact-integer bound a `wpo.seq` lives in."
  [k n]
  (and (string? k)
       (or (.test consts/PUB-RE k) (identical? k consts/NEUTRAL-BANKER))
       (number? n) (js/Number.isInteger n) (>= n 0) (<= n consts/AMT-MAX)))

(defn- read-seqs!
  "Load the sequence high-water marks. FALSE means the section is unreadable,
  and unlike every other section here that is fatal to the whole blob rather
  than a dropped record.

  The asymmetry is the point. Dropping a record loses evidence and the user can
  see the hole. Dropping a high-water mark RE-OFFERS A SPENT SLOT — `nextSeq`
  quietly returns to 1 and the wallet signs a second order on a slot the bank
  has already bound, which is the exact double-spend `seq` exists to prevent,
  and it does it with `storageError` null. So a mark that will not read is bad
  bytes, and bad bytes stop writing until the consumer has seen them.

  An ABSENT section is fine: blobs written before `seqs` existed have none."
  [raw out]
  (let [m (oget raw "seqs")]
    (cond
      (identical? m js/undefined) true
      (not (js-object? m)) false
      :else
      (let [ks (js/Object.keys m)]
        (loop [i 0]
          (if (>= i (.-length ^js ks))
            true
            (let [k (aget ks i)
                  n (unchecked-get m k)]
              (if (seq-mark? k n)
                (do (unchecked-set out k n) (recur (inc i)))
                false))))))))

(defn- json-size
  "How much room a value's JSON takes, in characters, or a number past any
  budget when it will not serialize at all. Characters rather than UTF-8 bytes:
  this is a budget for a section nobody here can read, not a wire limit."
  [v]
  (try (let [s (js/JSON.stringify v)]
         (if (string? s) (.-length s) js/Number.MAX_SAFE_INTEGER))
       (catch :default _ js/Number.MAX_SAFE_INTEGER)))

(defn- read-extra!
  "Every top-level key this version does not know, carried verbatim — the
  `stakes` promise generalized, so a newer wallet's sections survive an older
  one's write without the older one having to have heard of them.

  WHAT DISK HAS, AND NO MORE THAN EXTRA-MAX-BYTES OF IT. This reads into a fresh
  object that REPLACES the one in memory, so a section deleted on disk is not
  restored by our next write; and it stops carrying once the budget is spent, so
  an opaque section cannot grow without limit inside a blob whose real contents
  are receipts. Both halves are in the envelope note above."
  [raw out]
  (let [ks (js/Object.keys raw)
        budget (js-obj "left" EXTRA-MAX-BYTES)]
    (dotimes [i (.-length ^js ks)]
      (let [k (aget ks i)]
        (when-not (known-section? k)
          (let [v (unchecked-get raw k)
                ;; the key, its quotes, the colon and the comma, plus the value
                cost (+ (json-size v) (.-length k) 4)]
            (when (<= cost (oget budget "left"))
              (unchecked-set budget "left" (- (oget budget "left") cost))
              (unchecked-set out k v))))))))

(defn- snapshot-of
  "Index one parsed blob, or nil when it cannot be read at all — which today
  means only an unreadable `seqs` map; see `read-seqs!`."
  [raw]
  (let [snap (obj "receipts" (js/Map.) "declines" (js/Map.) "pending" (js/Map.)
                  "issuance" (js/Map.)
                  "oidOf" (js/Map.) "seqs" (js-obj) "stakes" nil "extra" (js-obj))]
    (when (read-seqs! raw (oget snap "seqs"))
      (read-settlements! raw "receipts" "wrc" (oget snap "receipts") (oget snap "oidOf"))
      (read-settlements! raw "declines" "wrj" (oget snap "declines") (oget snap "oidOf"))
      (read-pending! raw (oget snap "pending"))
      (read-issuance! raw (oget snap "issuance"))
      (read-extra! raw (oget snap "extra"))
      (let [st (oget raw "stakes")]
        (when (js/Array.isArray st) (unchecked-set snap "stakes" st)))
      snap)))

(defn- read-stored
  "Read and index what is on disk RIGHT NOW. `state` is one of:

    \"absent\" nothing stored — a genuinely fresh ledger, not an error;
    \"ok\"     parsed and indexed, `snap` holds it;
    \"bad\"    bytes exist that this version cannot read (unparseable, not an
              object, an unknown `v`, or an unreadable `seqs` map). `raw` holds
              them and they are never overwritten — see this file's header;
    \"off\"    localStorage itself refused to be read."
  [self]
  (let [box (obj "state" "absent" "raw" nil "snap" nil)]
    (try
      (let [raw (js/localStorage.getItem (oget self "_key"))]
        (when (string? raw)
          (unchecked-set box "raw" raw)
          (let [parsed (js/JSON.parse raw)
                snap (when (and (js-object? parsed) (identical? 1 (oget parsed "v")))
                       (snapshot-of parsed))]
            (if (some? snap)
              (do (unchecked-set box "state" "ok")
                  (unchecked-set box "snap" snap))
              (unchecked-set box "state" "bad")))))
      (catch :default _
        (unchecked-set box "state" (if (string? (oget box "raw")) "bad" "off"))))
    box))

;; ---- derived state ------------------------------------------------------------
;;
;; Nothing below is stored: it is rebuilt from the three record maps on every
;; load and every persist, which is what makes a merged store and a freshly
;; loaded one the same store.

(defn- slot-key
  "The bank's double-spend slot: (payer, bank, sequence). `seq` is the payer's
  own counter for one bank, and the bank's fold binds an order to exactly one of
  them — so two signed orders on one slot means at most one can ever settle and
  the payer cannot tell which. This wallet refuses to hold both."
  [po]
  (str (oget po "from") "|" (shapes/banker-of (oget po "cur")) "|" (oget po "seq")))

(defn- mark-slot! [po slots seqs me]
  (.add ^js slots (slot-key po))
  (when (identical? (oget po "from") me)
    (let [b (shapes/banker-of (oget po "cur"))
          n (oget po "seq")
          have (unchecked-get seqs b)]
      (when (and (some? b) (> n (if (number? have) have 0)))
        (unchecked-set seqs b n))))
  js/undefined)

(defn- mark-settlements! [m oid-of oids slots seqs me]
  (let [ks (js/Array.from (.keys ^js m))]
    (dotimes [i (.-length ^js ks)]
      (let [k (aget ks i)
            o (.get ^js oid-of k)]
        (when (digest? o) (.add ^js oids o))
        (mark-slot! (oget (.get ^js m k) "po") slots seqs me)))))

(defn- reindex!
  "Rebuild everything derived, and apply the one rule that is derived rather
  than stored: an order the bank has ANSWERED is no longer in flight, whoever
  recorded the answer. That is what lets a receipt folded in one tab release the
  hold a second tab is still showing.

  `_seqs` is RAISED here, never rebuilt: a high-water mark that fell back to the
  records would re-offer a slot after a prune, and `nextSeq`'s own docstring
  promises it will not."
  [self]
  (let [oids (js/Set.)
        slots (js/Set.)
        seqs (oget self "_seqs")
        me (oget self "idPub")
        oid-of (oget self "_oidOf")
        pending (oget self "_pending")]
    (mark-settlements! (oget self "_receipts") oid-of oids slots seqs me)
    (mark-settlements! (oget self "_declines") oid-of oids slots seqs me)
    (let [ks (js/Array.from (.keys ^js pending))]
      (dotimes [i (.-length ^js ks)]
        (let [k (aget ks i)]
          (if (.has ^js oids k)
            (.delete ^js pending k)
            (mark-slot! (.get ^js pending k) slots seqs me)))))
    (unchecked-set self "_oids" oids)
    (unchecked-set self "_slots" slots))
  js/undefined)

;; ---- merge --------------------------------------------------------------------

(defn- absorb-records! [into from oid-into oid-from prefix drop]
  (let [ks (js/Array.from (.keys ^js from))]
    (dotimes [i (.-length ^js ks)]
      (let [k (aget ks i)]
        (when-not (or (.has ^js into k)
                      (and (some? drop) (.has ^js drop (str prefix k))))
          (.set ^js into k (.get ^js from k))
          (when (and (some? oid-into) (.has ^js oid-from k))
            (.set ^js oid-into k (.get ^js oid-from k))))))))

(defn- absorb!
  "Union one on-disk snapshot into memory. Records are keyed by a digest of
  their own content, so \"already have this key\" IS \"already have this
  record\" and a union can never invent, alter or double-count one.

  Sections this version does not understand (`stakes` and anything newer) are
  TAKEN FROM DISK, replacing what memory held rather than merging into it. They
  are opaque here, so the writer that understands them is the one whose copy
  should survive — and \"replace\" is what makes a section deleted on disk stay
  deleted instead of being restored by our next write. Under a `drop` set,
  memory has just deleted records deliberately; the opaque sections are not
  ours to have an opinion about either way."
  [self snap drop]
  (absorb-records! (oget self "_receipts") (oget snap "receipts")
                   (oget self "_oidOf") (oget snap "oidOf") "wrc|" drop)
  (absorb-records! (oget self "_declines") (oget snap "declines")
                   (oget self "_oidOf") (oget snap "oidOf") "wrj|" drop)
  (absorb-records! (oget self "_pending") (oget snap "pending") nil nil "pending|" drop)
  (absorb-records! (oget self "_issuance") (oget snap "issuance") nil nil "wri|" drop)
  (let [seqs (oget self "_seqs")
        from (oget snap "seqs")
        ks (js/Object.keys from)]
    (dotimes [i (.-length ^js ks)]
      (let [k (aget ks i)
            n (unchecked-get from k)
            have (unchecked-get seqs k)]
        (when (> n (if (number? have) have 0)) (unchecked-set seqs k n)))))
  (let [st (oget snap "stakes")]
    (unchecked-set self "_stakes" (if (js/Array.isArray st) st #js [])))
  (unchecked-set self "_extra" (oget snap "extra"))
  js/undefined)

;; ---- the store ----------------------------------------------------------------

(defclass LedgerStore
  (constructor [this id-pub]
    (unchecked-set this "idPub" id-pub)
    (unchecked-set this "_key" (str consts/LEDGER-KEY-PREFIX id-pub))
    ;; Fires after every mutation (UI re-render).
    (unchecked-set this "onChange" (fn [] js/undefined))
    ;; Fires when a write to localStorage fails, and when what is stored cannot
    ;; be read — see the header. The in-memory ledger is still correct; what
    ;; failed is only durability.
    (unchecked-set this "onStorageError" (fn [_] js/undefined))
    (unchecked-set this "storageError" nil)
    ;; The stored bytes this build could not read, held so a consumer can save
    ;; them before deciding to overwrite. null when storage is readable.
    (unchecked-set this "unreadable" nil)
    (unchecked-set this "_onStorage" nil)
    ;; memory holds records disk has never seen — see `refresh!`
    (unchecked-set this "_dirty" false)
    ;; a sibling has written and this store has not looked yet — see `sync!`
    (unchecked-set this "_stale" false)
    ;; closed stores do not write; see `store-close`
    (unchecked-set this "_closed" false)
    (store-load! this)
    (attach-storage-listener! this)
    (register! this)))

(defn- fail-storage! [self err-name message]
  (let [err (obj "name" err-name "message" message "ts" (js/Date.now))]
    (unchecked-set self "storageError" err)
    (try ((oget self "onStorageError") err) (catch :default _ nil))
    false))

(def ^:private UNREADABLE-MSG
  (str "stored ledger could not be read; its bytes are in `unreadable` and will "
       "not be overwritten until acknowledgeUnreadable()"))

(def ^:private CLOSED-MSG
  "this LedgerStore has been closed; it no longer writes to localStorage")

;; ---- the same-document registry -----------------------------------------------
;;
;; The DOM `storage` event fires on every window of the origin EXCEPT the one
;; that wrote, so it cannot carry news between two LedgerStores in one document —
;; and one document holding two is the ordinary case here, since the key is the
;; identity and any view may open its own. Left alone, `S1.prune()` followed by
;; `S2.addOrder()` restores the pruned record from S2's stale memory, and no
;; number of retries fixes it because S2 is never told.
;;
;; So stores over one key find each other in process and notify each other after
;; a successful write. Membership lasts exactly as long as the `storage`
;; listener's does — `close()` releases both, and a store that is never closed
;; is retained by both, which is why `close()` is documented as required when a
;; store outlives its view.

(def ^:private REGISTRY (js/Map.))

(defn- register! [self]
  (let [k (oget self "_key")
        s (or (.get REGISTRY k) (let [fresh (js/Set.)] (.set REGISTRY k fresh) fresh))]
    (.add ^js s self))
  js/undefined)

(defn- unregister! [self]
  (let [k (oget self "_key")
        s (.get REGISTRY k)]
    (when (some? s)
      (.delete ^js s self)
      (when (zero? (.-size ^js s)) (.delete REGISTRY k))))
  js/undefined)

(defn- notify-siblings!
  "Tell every OTHER store over this key, in this document, that disk moved.

  MARKS THEM STALE; it does not converge them. Convergence is a full re-parse
  and re-shape of the whole blob, and history here is unbounded, so doing it
  eagerly for every registered store on every write is quadratic in the number
  of stores a page has ever opened — and a page that opens a store per view and
  never closes it opens a lot. `sync!` does the work at the top of whatever
  reads next, so a store nobody is looking at costs a boolean. `onChange` still
  fires immediately, because a UI that re-renders is exactly the reader that
  will do the converging.

  One sibling's failure is not this write's failure: each is wrapped."
  [self]
  (let [s (.get REGISTRY (oget self "_key"))]
    (when (some? s)
      (let [others (js/Array.from (.values ^js s))]
        (dotimes [i (.-length ^js others)]
          (let [o (aget others i)]
            (when-not (identical? o self)
              (unchecked-set o "_stale" true)
              (try ((oget o "onChange")) (catch :default _ nil))))))))
  js/undefined)

(defn- store-load! [self]
  (unchecked-set self "_receipts" (js/Map.))
  (unchecked-set self "_declines" (js/Map.))
  (unchecked-set self "_pending" (js/Map.))
  (unchecked-set self "_issuance" (js/Map.))
  (unchecked-set self "_oidOf" (js/Map.))
  (unchecked-set self "_oids" (js/Set.))
  (unchecked-set self "_slots" (js/Set.))
  (unchecked-set self "_seqs" (js-obj))
  (unchecked-set self "_stakes" #js [])
  (unchecked-set self "_extra" (js-obj))
  (unchecked-set self "unreadable" nil)
  ;; memory is about to be exactly disk, so nothing is held back from it
  (unchecked-set self "_dirty" false)
  (let [box (read-stored self)
        state (oget box "state")]
    (if (identical? state "ok")
      (do (absorb! self (oget box "snap") nil)
          (unchecked-set self "storageError" nil))
      (if (identical? state "bad")
        (do (unchecked-set self "unreadable" (oget box "raw"))
            (fail-storage! self "UnreadableStorageError" UNREADABLE-MSG))
        (if (identical? state "off")
          (fail-storage! self "StorageUnavailableError" "localStorage could not be read")
          (unchecked-set self "storageError" nil)))))
  (reindex! self)
  js/undefined)

(defn- refresh!
  "Converge on what another writer put on disk.

  A CLEAN store reloads: memory holds nothing disk does not, so replacing it is
  the cheapest way to be exactly right, deletions included.

  A DIRTY store — one whose last write failed, so memory holds records disk has
  never seen — UNIONS instead. Reloading it would replace the only copy of a
  receipt with a blob that never contained one, which is the retention rule
  broken from the inside: a record lost to a convergence step rather than to a
  cap. It converges anyway on its next successful write."
  [self]
  (if (truthy? (oget self "_dirty"))
    (let [box (read-stored self)
          state (oget box "state")]
      (when (identical? state "ok")
        (absorb! self (oget box "snap") nil)
        ;; readable bytes now, so whatever we were refusing to overwrite is gone
        ;; from disk — the block has nothing left to protect
        (unchecked-set self "unreadable" nil)
        (unchecked-set self "storageError" nil))
      (when (identical? state "bad")
        (unchecked-set self "unreadable" (oget box "raw")))
      (reindex! self))
    (store-load! self))
  (unchecked-set self "_stale" false)
  js/undefined)

(defn- sync!
  "Converge, if someone has told us disk moved since we last looked.

  At the top of every reader and of `prune`, which is what makes the staleness
  flag safe: nothing observes this store's memory without first taking the news
  it has been sent. The mutators do not need it — `persist!` reads and merges
  disk itself, which is the same convergence by a shorter route."
  [self]
  (when (truthy? (oget self "_stale")) (refresh! self))
  js/undefined)

(defn- attach-storage-listener!
  "Converge when ANOTHER TAB writes. Without it a tab only learns about a
  sibling's receipts on its own next mutation, which is exactly when it is about
  to make a decision that depends on them.

  This event never reaches a store in the SAME document as the writer — the DOM
  fires it on every window of the origin except that one — which is what the
  in-process registry above is for. The two are complements, not alternatives."
  [self]
  (try
    (let [g (js* "globalThis")]
      (when (js-fn? (oget g "addEventListener"))
        (let [h (fn [e]
                  (try
                    ;; a null key means the whole store was cleared
                    (let [k (oget e "key")]
                      (when (or (nil? k) (identical? k (oget self "_key")))
                        ;; stale, not reloaded — same lazy convergence the
                        ;; registry uses, and for the same reason
                        (unchecked-set self "_stale" true)
                        (try ((oget self "onChange")) (catch :default _ nil))))
                    (catch :default _ nil)))]
          (unchecked-set self "_onStorage" h)
          (.addEventListener ^js g "storage" h))))
    (catch :default _ nil))
  js/undefined)

(defn- blob-of [self]
  (let [oid-of (oget self "_oidOf")
        section (fn [m] (.map (js/Array.from (.entries ^js m))
                              (fn [e] (obj "k" (aget e 0) "a" (aget e 1)))))
        settlements (fn [m] (.map (js/Array.from (.entries ^js m))
                                  (fn [e] (obj "k" (aget e 0)
                                               "o" (.get ^js oid-of (aget e 0))
                                               "a" (aget e 1)))))
        b (obj "v" 1
               "receipts" (settlements (oget self "_receipts"))
               "declines" (settlements (oget self "_declines"))
               "pending" (section (oget self "_pending"))
               "issuance" (section (oget self "_issuance"))
               "seqs" (oget self "_seqs")
               "stakes" (oget self "_stakes"))
        extra (oget self "_extra")
        ks (js/Object.keys extra)]
    (dotimes [i (.-length ^js ks)]
      (let [k (aget ks i)]
        (when-not (known-section? k) (unchecked-set b k (unchecked-get extra k)))))
    b))

(defn- dirty! [self]
  (unchecked-set self "_dirty" true)
  js/undefined)

(defn- raw-bytes
  "The stored string right now, or nil. Never throws: a store that cannot be
  read cannot be compared either, and the caller treats that as \"unchanged\" so
  a broken localStorage produces one failing write instead of MAX-CAS-TRIES of
  them."
  [self]
  (try (let [v (js/localStorage.getItem (oget self "_key"))]
         (if (string? v) v nil))
       (catch :default _ js/undefined)))

(defn- unchanged?
  "Is disk still holding exactly what we merged from? `js/undefined` from
  `raw-bytes` means unreadable, which answers yes — see there."
  [self seen]
  (let [now (raw-bytes self)]
    (or (identical? now js/undefined) (identical? now seen))))

(defn- write-bytes! [self payload]
  (try
    (js/localStorage.setItem (oget self "_key") payload)
    (unchecked-set self "storageError" nil)
    true
    (catch :default e
      (let [nm (try (or (oget e "name") "Error") (catch :default _ "Error"))
            msg (try (or (oget e "message") (str e)) (catch :default _ ""))]
        (fail-storage! self nm msg)))))

(defn- write!
  "Serialize memory and store it, unconditionally. Only two callers: the CAS in
  `persist!`, which has already decided the bytes are safe to replace, and
  `acknowledgeUnreadable`, whose whole purpose is to replace bytes nobody could
  merge with."
  [self]
  (if (truthy? (oget self "_closed"))
    (do (dirty! self) (fail-storage! self "StoreClosedError" CLOSED-MSG))
    (let [ok (write-bytes! self (js/JSON.stringify (blob-of self)))]
      (if ok
        (do (unchecked-set self "_dirty" false) true)
        (do (dirty! self) false)))))

(defn- persist!
  "Read-modify-write, serialized by a COMPARE-AND-SWAP over the stored bytes.
  Returns true only when this call's state reached disk.

  Re-reads what is on disk, unions it into memory by the content digests every
  record is keyed by, reindexes, and writes the union — so two tabs on one
  identity converge instead of overwriting each other. `drop` is the set of
  records THIS call is deliberately removing (`prune` only), the one case where
  the disk copy must not come back.

  The merge alone is not enough and this file's header says why: a sibling that
  completes its own write between our read and our write is simply overwritten.
  So the bytes are re-read immediately before the setItem and compared with what
  we merged from — different means start over from what they wrote — and read
  back afterwards, because a write that landed on top of ours has to be re-merged
  rather than believed. Bounded at MAX-CAS-TRIES; each retry merges from newer
  bytes, so a retry is progress.

  Never drops a record to make room; a failed write is REPORTED and the session
  carries on with its in-memory truth. Bytes this version could not read are
  never overwritten at all, and neither is anything after `close()`."
  [self drop]
  (if (truthy? (oget self "_closed"))
    (do (dirty! self) (fail-storage! self "StoreClosedError" CLOSED-MSG))
    (loop [attempt 0]
      (let [blocked (some? (oget self "unreadable"))
            box (if blocked nil (read-stored self))]
        (when (some? box)
          (let [state (oget box "state")]
            (when (identical? state "ok") (absorb! self (oget box "snap") drop))
            (when (identical? state "bad") (unchecked-set self "unreadable" (oget box "raw")))))
        (reindex! self)
        ;; persist! reads and merges disk on every attempt, so any sibling news
        ;; is already absorbed by here
        (unchecked-set self "_stale" false)
        (if (some? (oget self "unreadable"))
          (do (dirty! self) (fail-storage! self "UnreadableStorageError" UNREADABLE-MSG))
          (let [seen (when (some? box) (oget box "raw"))
                payload (js/JSON.stringify (blob-of self))
                last? (>= attempt (dec MAX-CAS-TRIES))]
            (if (and (not last?) (not (unchanged? self seen)))
              ;; a writer landed in the window: this merge came from stale bytes
              (recur (inc attempt))
              (let [ok (write! self)]
                (if (and ok (not last?) (not (unchanged? self payload)))
                  ;; and one landed on top of ours: re-merge rather than accept
                  (recur (inc attempt))
                  (do (when ok (notify-siblings! self))
                      ok))))))))))

(defn- changed! [self]
  (try ((oget self "onChange")) (catch :default _ nil))
  js/undefined)

;; ---- mutation -----------------------------------------------------------------
;;
;; The three write outcomes, spelled out once. See this file's header for why
;; they are integers and not a string union or a result object: 0 is the only
;; falsy member, so `if (await store.addOrder(o))` still means exactly "was it
;; recorded", while `=== true` stops matching and fails loudly on a success path
;; rather than quietly on a rejection one.

(def ^:private REJECTED 0)
(def ^:private STORED 1)
(def ^:private MEMORY-ONLY 2)

(defn- wrote [ok] (if ok STORED MEMORY-ONLY))

(defn- store-add-order
  "Record an order this wallet has signed and delivered. Idempotent by orderId;
  refuses an order the bank has already answered, and refuses a second order on
  a (payer, bank, seq) slot that is already taken.

  0 rejected / 1 recorded and on disk / 2 recorded in memory only."
  [self po]
  (-> (awaits [_ (js/Promise.resolve nil)]
        (let [o (shapes/order-shape po js/undefined)]
          (if (nil? o)
            REJECTED
            (awaits [k (shapes/order-id o)]
              (let [pending (oget self "_pending")]
                (if (or (.has ^js pending k)
                        ;; an order the bank has ANSWERED, by its own id. Nearly
                        ;; always caught by the slot check on the next line —
                        ;; same order id means same slot — but not when the
                        ;; stored `o` beside a settlement is not the id of the
                        ;; order beside it, which is exactly the trade
                        ;; `read-settlements!` documents taking. Cheap, and the
                        ;; conservative half of it
                        (.has ^js (oget self "_oids") k)
                        (.has ^js (oget self "_slots") (slot-key o)))
                  REJECTED
                  (do (.set ^js pending k o)
                      (let [ok (persist! self nil)]
                        (changed! self)
                        (wrote ok)))))))))
      (.catch (fn [_] REJECTED))))

(defn- store-apply-settlement [self art want-t]
  (-> (awaits [_ (js/Promise.resolve nil)]
        (let [s (shapes/settlement-shape art js/undefined)]
          (if (or (nil? s) (not (identical? (oget s "t") want-t)))
            REJECTED
            (awaits [k (shapes/receipt-key s)
                     oid (shapes/order-id (oget s "po"))]
              (let [m (oget self (if (identical? want-t "wrc") "_receipts" "_declines"))]
                (if (.has ^js m k)
                  REJECTED ; already folded — idempotent, and folding twice would double a balance
                  (do (.set ^js m k s)
                      (.set ^js (oget self "_oidOf") k oid)
                      ;; persist! reindexes, which is what takes the answered
                      ;; order out of flight — here and in every other tab
                      (let [ok (persist! self nil)]
                        (changed! self)
                        (wrote ok)))))))))
      (.catch (fn [_] REJECTED))))

(defn- store-apply-issuance
  "Fold one issuance receipt — minted money made visible OUTSIDE banca, so the
  hub and the games see a balance the bank's own log gave finality to.

  ONLY WHEN `to` IS THIS IDENTITY, and a wri to anyone else is REJECTED rather
  than stored-but-not-folded. A settlement can touch this wallet from either
  side (a debit as payer, a credit as payee), so the store keeps both and the
  fold sorts them out; a wri has exactly one beneficiary, so a receipt naming
  someone else can never fold here — storing it would be dead weight in a
  ledger with no retention caps, and every stored issuance folding is a
  simpler invariant than a filter. (`balances` still re-checks `to` — storage
  is re-shaped on load, not re-gated, so the fold must not trust it either.)

  Issuance CREDITS and never debits: money is created at the bank and lands on
  the recipient. There is no negative direction — a burn has no wri at all;
  see buildIssuanceReceipt.

  Idempotent by issuanceKey, which is blind to `ts`: a receipt re-issued with
  a fresh timestamp folds once. Same structural discipline as the other
  mutators — verify with verifyIssuanceReceipt FIRST, this checks shape only.

  0 rejected / 1 recorded and on disk / 2 recorded in memory only."
  [self art]
  (-> (awaits [_ (js/Promise.resolve nil)]
        (let [w (shapes/issuance-shape art js/undefined)]
          (if (or (nil? w)
                  (not (identical? (oget w "to") (oget self "idPub"))))
            REJECTED
            (awaits [k (shapes/issuance-key w)]
              (let [m (oget self "_issuance")]
                (if (.has ^js m k)
                  REJECTED ; already folded — folding twice would double a credit
                  (do (.set ^js m k w)
                      (let [ok (persist! self nil)]
                        (changed! self)
                        (wrote ok)))))))))
      (.catch (fn [_] REJECTED))))

;; ---- reading ------------------------------------------------------------------

(defn- values-of [m] (js/Array.from (.values ^js m)))

(defn- past-hold?
  "Has this order stopped being something a bank could still settle?

  NOT `expired`, which is the artifact layer's exact question and answers at
  `exp` to the millisecond. This is the LEDGER's question, and it has to allow
  the same clock skew everything else here allows: `ts?` accepts a bank's
  timestamp ±SKEW-MS and `settlement-shape` accepts a settlement SKEW-MS before
  its order, so a bank one millisecond behind this wallet's clock settles an
  order this wallet has just released — and for that window `available` claimed
  money that was in fact still committed. Releasing at `exp + SKEW-MS` costs a
  two-minute wait and nothing else; the paper is never dropped either way.

  `expired` itself is unchanged, and `expiredOrders` uses THIS, so an order is
  never both held and listed as expired."
  [po now]
  (shapes/expired po (- now consts/SKEW-MS)))

;; ---- exact arithmetic ---------------------------------------------------------
;;
;; The fold is over BigInt, and this is not fastidiousness. Doubles are exact on
;; integers only to 2^53 and one amount may be 2^50 (consts/AMT-MAX), so EIGHT
;; maximal receipts is the whole headroom — after which `+` stops being
;; addition. It stops in the worst possible way, too: silently, and
;; ORDER-DEPENDENTLY. Eight receipts of 2^50 and two of 1 total 2^53+2, which is
;; a perfectly representable double; folded big-first a double fold returns
;; 2^53, folded small-first it returns 2^53+2, and the wallet's answer to "how
;; much do I have" depends on the sequence its receipts happened to arrive in.
;; Two devices holding identical evidence would disagree.
;;
;; BigInt is exact and associative, so the total is a function of the SET of
;; receipts and nothing else — which is the only thing a receipt-first ledger
;; can afford to promise. The Number fields stay, because every consumer of this
;; is a UI that wants a number; what is new is that they are never the only
;; answer. `*Exact` carries the BigInt and `exact` says whether the three
;; Numbers are the same value or a rounding of it.

(def ^:private ZERO (js/BigInt 0))

(defn- b+ [a b] (js* "(~{} + ~{})" a b))
(defn- b- [a b] (js* "(~{} - ~{})" a b))

(defn- exact-as-number?
  "Does this BigInt survive the round trip through a double? Total: a magnitude
  past 2^1024 makes Number() infinite and BigInt() throw."
  [b]
  (try (identical? (js/BigInt (js/Number b)) b) (catch :default _ false)))

(defn- store-balances [self now-ms]
  (sync! self)
  (let [out (js-obj)
        me (oget self "idPub")
        now (if (number? now-ms) now-ms (js/Date.now))
        touch (fn [cur]
                (let [e (unchecked-get out cur)]
                  (if (identical? e js/undefined)
                    (let [n (obj "settled" 0 "held" 0 "available" 0
                                 "settledExact" ZERO "heldExact" ZERO "availableExact" ZERO
                                 "exact" true)]
                      (unchecked-set out cur n)
                      n)
                    e)))
        recs (values-of (oget self "_receipts"))
        ords (values-of (oget self "_pending"))]
    ;; settled: credits for orders paid TO me, debits for orders paid BY me.
    ;; A self-payment hits both arms and nets to zero, which is what it is.
    (dotimes [i (.-length ^js recs)]
      (let [po (oget (aget recs i) "po")
            e (touch (oget po "cur"))
            amt (js/BigInt (oget po "amt"))]
        (when (identical? (oget po "to") me)
          (unchecked-set e "settledExact" (b+ (oget e "settledExact") amt)))
        (when (identical? (oget po "from") me)
          (unchecked-set e "settledExact" (b- (oget e "settledExact") amt)))))
    ;; issuance: settled CREDITS only, and only to me. applyIssuance already
    ;; refuses a wri whose `to` is someone else, but storage is re-shaped on
    ;; load rather than re-gated, so the fold re-checks — a hand-edited blob
    ;; must not credit this wallet with someone else's mint. Never a debit:
    ;; issuance creates money, and a burn has no wri at all.
    (let [iss (values-of (oget self "_issuance"))]
      (dotimes [i (.-length ^js iss)]
        (let [w (aget iss i)]
          (when (identical? (oget w "to") me)
            (let [e (touch (oget w "cur"))]
              (unchecked-set e "settledExact"
                             (b+ (oget e "settledExact") (js/BigInt (oget w "amt")))))))))
    ;; held: only OUTGOING pending, and only while it can still settle. An
    ;; incoming order someone showed us is not money we have, and it is not money
    ;; we owe either — but its currency still gets a row, because "every currency
    ;; this wallet holds paper in appears" is a rule a UI can render, and
    ;; "…unless the only paper is incoming" is not.
    ;;
    ;; An EXPIRED order is out of the hold. `exp` is inside what the payer signed,
    ;; so an order past it can never be settled by an honest bank, and holding
    ;; against it depressed `available` forever with no exit but a user-driven
    ;; prune. The paper is still there — `expiredOrders` lists it, `prune` clears
    ;; it — but it stops costing the user money that nobody can take.
    (dotimes [i (.-length ^js ords)]
      (let [po (aget ords i)
            e (touch (oget po "cur"))]
        (when (and (identical? (oget po "from") me) (not (past-hold? po now)))
          (unchecked-set e "heldExact" (b+ (oget e "heldExact") (js/BigInt (oget po "amt")))))))
    (let [ks (js/Object.keys out)]
      (dotimes [i (.-length ^js ks)]
        (let [e (unchecked-get out (aget ks i))
              s (oget e "settledExact")
              h (oget e "heldExact")
              a (b- s h)]
          (unchecked-set e "availableExact" a)
          (unchecked-set e "settled" (js/Number s))
          (unchecked-set e "held" (js/Number h))
          (unchecked-set e "available" (js/Number a))
          (unchecked-set e "exact" (and (exact-as-number? s)
                                        (exact-as-number? h)
                                        (exact-as-number? a))))))
    out))

(defn- store-expired-orders
  "The outgoing pending orders that have stopped being held, so this is how a UI
  shows the user what is no longer committed and offers to prune it. Uses
  `past-hold?`, the same predicate `held` uses — an order is never in both
  lists, and never in neither."
  [self now-ms]
  (sync! self)
  (let [now (if (number? now-ms) now-ms (js/Date.now))
        out #js []
        ords (values-of (oget self "_pending"))]
    (dotimes [i (.-length ^js ords)]
      (let [po (aget ords i)]
        (when (past-hold? po now) (.push out po))))
    out))

(defn- store-next-seq
  "The payer's next sequence number for the BANK behind `cur` — derived from
  this wallet's own history and nothing else, which is the whole point: the
  payer owns the sequence, the bank only checks it.

  Per (payer, bank), NOT per currency: a bank folds one sequence per payer
  across every code it issues. Monotone over pending, settled AND declined — a
  declined slot is not reused, because a bank that declined `seq` 7 for one
  reason may still have recorded it, and re-using it invites a fold conflict
  that costs more than a skipped integer. Monotone across a `prune` too, and
  across a reload: the high-water mark is persisted, not re-derived from the
  records, so pruning a receipt cannot re-offer the slot it spent.

  ADVISORY. It reads a mark; it does not take one. Two calls in one tick return
  the same number and two tabs return the same number, and two signed orders on
  one slot is the exact failure `seq` exists to prevent. Use `reserveSeq` to
  allocate; use this to display.

  ESCROW IS NOT TRACKED HERE, AND A LOCK SHARES THIS SPACE. A `wlk` claims the
  same (payer, bank, seq) slot a `wpo` does — one account moving its own money,
  one slot per act — but this store holds no locks, so a mark it hands out has
  not been checked against any. Nothing in the suite hits this today: every
  consumer that locks (banca, and bursa after it) REPLICATES the bank's log and
  takes its slot from that fold, which sees locks and payments alike. A wallet
  that does not replicate and signs both must reserve from one counter it owns
  and not from this one, until this store learns about locks."
  [self cur]
  (sync! self)
  (let [banker (shapes/banker-of cur)]
    (if (nil? banker)
      1
      (let [mark (unchecked-get (oget self "_seqs") banker)]
        (inc (if (number? mark) mark 0))))))

(defn- store-reserve-seq
  "Take the next sequence slot for the bank behind `cur` and PERSIST the
  allocation before handing it back. Resolves the slot, or REJECTS.

  This is what `nextSeq` cannot be. The compare-and-swap in `persist!` is the
  serialization: the mark is merged up from disk, incremented, and written under
  a check that disk did not move in between, so a second tab that reserves next
  sees the number this one took. A slot handed out here is never handed out
  again — not by a reload, not by a prune, not by a sibling tab — which is what
  makes it safe to sign an order against.

  IT REJECTS WHEN THE ALLOCATION DID NOT REACH DISK, and this is the whole point
  of the call. The earlier version threw `persist!`'s answer away and resolved
  the number regardless: under a blocked write (`unreadable`) or a failed one
  (quota) it handed out a slot nothing had recorded, so the next store over the
  same key handed out the same slot, and two signed orders on one (payer, bank,
  seq) is the precise double-spend `seq` exists to prevent. There is no honest
  number to resolve there, so it rejects and the caller does not sign.

  Also rejects on anything an order cannot be denominated in — an unparseable
  id, or the quote-only neutral unit — and on a bank whose sequence space is
  spent, where the next slot would be a number no `wpo` may carry."
  [self cur]
  (awaits [_ (js/Promise.resolve nil)]
    (let [banker (shapes/banker-of cur)]
      (when-not (shapes/is-payable-currency cur)
        (throw (js/Error. "wallet-kit: reserveSeq needs a payable currency id")))
      ;; merge first, so the mark includes whatever any other tab has taken
      (persist! self nil)
      (let [seqs (oget self "_seqs")
            mark (unchecked-get seqs banker)
            n (inc (if (number? mark) mark 0))]
        (when (> n consts/AMT-MAX)
          (throw (js/Error. (str "wallet-kit: this bank's sequence space is exhausted "
                                 "— no slot left that an order may carry"))))
        ;; the mark moves in memory FIRST and is not rolled back on failure: a
        ;; skipped integer costs nothing, and a slot this session has already
        ;; named must never be offered again even if it never reached disk
        (unchecked-set seqs banker n)
        (let [ok (persist! self nil)]
          (changed! self)
          (when-not ok
            (throw (js/Error.
                    (str "wallet-kit: reserveSeq could not persist slot " n " ("
                         (let [e (oget self "storageError")]
                           (if (some? e) (oget e "name") "unknown"))
                         "); it is NOT safe to sign an order against an unrecorded slot"))))
          n)))))

;; ---- portability --------------------------------------------------------------
;;
;; The export is the user's own data in the plainest shape that exists: three
;; arrays of the artifacts themselves. The dedup keys are NOT exported — they
;; are derivable, and re-deriving them on import is what lets an import from an
;; untrusted file be checked rather than believed.
;;
;; `seqs` IS exported, and is the one thing here that is not derivable from the
;; records. It has to be: the mark is deliberately higher than the records
;; justify after a `prune`, and an export that left it out sent device B a
;; ledger whose sequence marks had been reset to zero. B would then offer slot 1
;; for a bank whose slot 1 device A had already spent — the same re-offered-slot
;; regression the persisted mark exists to prevent, arriving over the
;; portability path instead. It merges by MAX on import, like every other
;; sighting of a high-water mark in this file, and is re-validated on the way in
;; because an export file is untrusted input however it arrived.

(defn- store-export-json [self]
  (sync! self)
  (js/JSON.stringify
   (obj "v" 1
        "idPub" (oget self "idPub")
        "receipts" (values-of (oget self "_receipts"))
        "declines" (values-of (oget self "_declines"))
        "pending" (values-of (oget self "_pending"))
        "issuance" (values-of (oget self "_issuance"))
        "seqs" (oget self "_seqs")
        "stakes" (oget self "_stakes"))))

(defn- import-seqs!
  "Raise this wallet's marks to the export's, entry by entry. Returns whether
  anything moved. MAX, never last-wins: an import must not be able to lower a
  mark, or it would re-offer a slot this device has already spent. Entries that
  do not validate are skipped — an import is an ADDITIVE merge into a ledger
  that is already correct, so a bad entry here is not the bad-bytes situation
  `read-seqs!` faces, where the mark on disk was the only copy."
  [self from]
  (let [moved (js-obj "b" false)]
    (when (js-object? from)
      (let [seqs (oget self "_seqs")
            ks (js/Object.keys from)]
        (dotimes [i (.-length ^js ks)]
          (let [k (aget ks i)
                n (unchecked-get from k)
                have (unchecked-get seqs k)]
            (when (and (seq-mark? k n) (> n (if (number? have) have 0)))
              (unchecked-set seqs k n)
              (unchecked-set moved "b" true))))))
    (truthy? (oget moved "b"))))

(defn- party?
  "Is this wallet's identity one of the two parties to that order? The second
  half of the import gate, and the half that does not depend on the blob being
  honest about whose ledger it is."
  [self po]
  (let [me (oget self "idPub")]
    (or (identical? (oget po "from") me) (identical? (oget po "to") me))))

(defn- import-one [self art kind added]
  ;; `0` is TRUTHY in ClojureScript, so the mutators' rejection code has to be
  ;; compared, never tested for truthiness
  (let [recorded? (fn [r] (not (identical? r REJECTED)))
        count! (fn [r]
                 (when (recorded? r) (unchecked-set added "n" (inc (oget added "n"))))
                 nil)]
    (cond
      (identical? kind "pending")
      (awaits [o (shapes/verify-order art js/undefined)]
        (if (or (nil? o) (not (party? self o)))
          nil
          (awaits [r (store-add-order self o)] (count! r))))

      ;; an issuance receipt: the SIGNATURE is verified here like every other
      ;; record in an export file, and the party hygiene is `to` = this
      ;; identity — the recipient is the only party whose money a wri moves,
      ;; and store-apply-issuance enforces the same gate again
      (identical? kind "wri")
      (awaits [w (shapes/verify-issuance-receipt art js/undefined js/undefined)]
        (if (or (nil? w) (not (identical? (oget w "to") (oget self "idPub"))))
          nil
          (awaits [r (store-apply-issuance self w)] (count! r))))

      :else
      (awaits [s (shapes/verify-settlement art js/undefined js/undefined)]
        (if (or (nil? s)
                (not (identical? (oget s "t") kind))
                (not (party? self (oget s "po"))))
          nil
          (awaits [r (store-apply-settlement self s kind)] (count! r)))))))

(defn- import-section [self arr i kind added]
  (if (or (not (js/Array.isArray arr)) (>= i (.-length ^js arr)))
    (js/Promise.resolve nil)
    (-> (import-one self (aget arr i) kind added)
        (.catch (fn [_] nil)) ; one bad record must not abort the import
        (.then (fn [_] (import-section self arr (inc i) kind added))))))

(defn- store-import-json
  "Merge an exported ledger. EVERY signature is verified — an export file is
  untrusted input, however it reached this device. Returns how many RECORDS were
  new. Never destructive: an import only ever adds, and the sequence marks it
  carries can only ever be raised, never lowered (see `import-seqs!`). The
  returned count does not include a raised mark; it is a count of records.

  IT MUST BE YOUR OWN LEDGER. The blob's `idPub` has to be this store's
  identity, and every record has to name that identity as payer or payee. Both,
  because they fail differently: without the first, someone else's export merges
  wholesale and their currencies, receipts and pending orders show up as yours;
  without the second, a blob that merely CLAIMS your idPub carries whatever it
  likes. This is the kit's only documented untrusted-input path and the only
  place it verifies signatures, and neither of those helps at all against a file
  whose artifacts are perfectly valid and simply not about you."
  [self raw]
  (-> (awaits [_ (js/Promise.resolve nil)]
        (let [blob (try (if (string? raw) (js/JSON.parse raw) raw) (catch :default _ nil))
              added (js-obj "n" 0)]
          (if-not (and (js-object? blob)
                       (identical? 1 (oget blob "v"))
                       (identical? (oget blob "idPub") (oget self "idPub")))
            0
            (awaits [_ (import-section self (oget blob "receipts") 0 "wrc" added)
                     _ (import-section self (oget blob "declines") 0 "wrj" added)
                     _ (import-section self (oget blob "pending") 0 "pending" added)
                     _ (import-section self (oget blob "issuance") 0 "wri" added)]
              ;; the marks last, and persisted even when no record was new: a
              ;; blob whose records are all duplicates may still carry a higher
              ;; mark, and that mark is the whole reason it is exported
              (when (import-seqs! self (oget blob "seqs"))
                (persist! self nil)
                (changed! self))
              (oget added "n")))))
      (.catch (fn [_] 0))))

;; ---- pruning (user-driven ONLY) -----------------------------------------------

(defn- plan-prune! [m keep kind out]
  (let [ks (js/Array.from (.keys ^js m))]
    (dotimes [i (.-length ^js ks)]
      (let [k (aget ks i)]
        ;; JS truthiness on purpose: `keep` is the user's own callback and a
        ;; predicate that answers 0 or "" means "no" to whoever wrote it
        (when-not (truthy? (keep (.get ^js m k) kind))
          (.push out (obj "m" m "k" k "d" (str kind "|" k))))))))

(defn- store-prune
  "Drop records the USER has decided to drop. `keep(artifact, kind)` returns
  true to keep; `kind` is \"wrc\", \"wrj\" or \"pending\". Returns how many
  records went.

  ALL-OR-NOTHING. Every decision is taken before anything is removed, so a
  predicate that throws half way through leaves memory and disk exactly as they
  were and the error reaches the caller who wrote the predicate. Deleting some
  records, then throwing, used to leave memory permanently ahead of disk with
  nothing to say so.

  LOCAL. There are no tombstones: another store still holding a pruned record in
  memory restores it on its next write. The `storage` event makes another TAB
  converge first, and the in-process registry does the same for a store in this
  same document, which that event can never reach — see this file's header.

  THE COUNT IS OF RECORDS REMOVED, NOT OF BYTES WRITTEN. Removal always happens;
  whether it reached disk is `storageError`, checked after the call like any
  other durability question. Unlike the mutators there is no room in a count to
  say both, and the count is the answer the caller asked for.

  This is the only deletion path in the kit and nothing calls it on the user's
  behalf — see this file's header on retention."
  [self keep]
  (sync! self)
  (let [plan #js []]
    (plan-prune! (oget self "_receipts") keep "wrc" plan)
    (plan-prune! (oget self "_declines") keep "wrj" plan)
    (plan-prune! (oget self "_pending") keep "pending" plan)
    (plan-prune! (oget self "_issuance") keep "wri" plan)
    (let [drop (js/Set.)]
      (dotimes [i (.-length ^js plan)]
        (let [p (aget plan i)]
          (.delete ^js (oget p "m") (oget p "k"))
          (.delete ^js (oget self "_oidOf") (oget p "k"))
          (.add ^js drop (oget p "d"))))
      (when (pos? (.-length ^js plan))
        (persist! self drop)
        (changed! self))
      (.-length ^js plan))))

;; ---- recovery -----------------------------------------------------------------

(defn- store-acknowledge-unreadable
  "\"I have seen `unreadable` and I accept losing it.\" Clears the block and
  writes this session's ledger over the bytes. Returns false when there was
  nothing to acknowledge.

  The one call in this kit that destroys data a user might have wanted, which is
  why nothing calls it automatically and why the bytes are handed over first."
  [self]
  (if (nil? (oget self "unreadable"))
    false
    (do (unchecked-set self "unreadable" nil)
        (unchecked-set self "storageError" nil)
        (reindex! self)
        (let [ok (write! self)]
          (when ok (notify-siblings! self))
          (changed! self)
          ok))))

(defn- store-close
  "Retire this store: detach the `storage` listener, leave the same-document
  registry, and STOP WRITING.

  The last clause is not tidiness. `close()` used to detach the listener and
  nothing else, which produced the worst possible object: a store that no longer
  hears about anything on disk and still writes over it — a permanently stale
  writer whose every mutation resurrected whatever it was holding when it was
  closed. Reading still works, and the in-memory ledger stays correct and
  inspectable; mutators report 2 (recorded in memory only) and `storageError`
  names StoreClosedError. There is no reopen: construct a new store."
  [self]
  (try
    (let [h (oget self "_onStorage")
          g (js* "globalThis")]
      (when (and (some? h) (js-fn? (oget g "removeEventListener")))
        (.removeEventListener ^js g "storage" h)))
    (catch :default _ nil))
  (unchecked-set self "_onStorage" nil)
  (unregister! self)
  (unchecked-set self "_closed" true)
  js/undefined)

;; ---- prototype ----------------------------------------------------------------
;;
;; String keys via unchecked-set, never `(defn ^:export …)` or a method literal:
;; this kit builds :advanced, and a renamed method is a green build that dies at
;; the first call from JavaScript.

(let [proto (.-prototype LedgerStore)]
  (unchecked-set proto "load"
                 (fn [] (this-as self (store-load! self) (changed! self) js/undefined)))
  (unchecked-set proto "balances"
                 (fn [now-ms] (this-as self (store-balances self now-ms))))
  (unchecked-set proto "addOrder"
                 (fn [po] (this-as self (store-add-order self po))))
  (unchecked-set proto "applyReceipt"
                 (fn [rc] (this-as self (store-apply-settlement self rc "wrc"))))
  (unchecked-set proto "applyDecline"
                 (fn [rj] (this-as self (store-apply-settlement self rj "wrj"))))
  (unchecked-set proto "applyIssuance"
                 (fn [wi] (this-as self (store-apply-issuance self wi))))
  (unchecked-set proto "issuances"
                 (fn [] (this-as self (sync! self) (values-of (oget self "_issuance")))))
  (unchecked-set proto "pendingOrders"
                 (fn [] (this-as self (sync! self) (values-of (oget self "_pending")))))
  (unchecked-set proto "expiredOrders"
                 (fn [now-ms] (this-as self (store-expired-orders self now-ms))))
  (unchecked-set proto "receipts"
                 (fn [] (this-as self (sync! self) (values-of (oget self "_receipts")))))
  (unchecked-set proto "declines"
                 (fn [] (this-as self (sync! self) (values-of (oget self "_declines")))))
  (unchecked-set proto "nextSeq"
                 (fn [cur] (this-as self (store-next-seq self cur))))
  (unchecked-set proto "reserveSeq"
                 (fn [cur] (this-as self (store-reserve-seq self cur))))
  (unchecked-set proto "exportJson"
                 (fn [] (this-as self (store-export-json self))))
  (unchecked-set proto "importJson"
                 (fn [raw] (this-as self (store-import-json self raw))))
  (unchecked-set proto "prune"
                 (fn [keep] (this-as self (store-prune self keep))))
  (unchecked-set proto "acknowledgeUnreadable"
                 (fn [] (this-as self (store-acknowledge-unreadable self))))
  (unchecked-set proto "close"
                 (fn [] (this-as self (store-close self)))))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/wallet-kit.git