-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathsupertag-core-persistence.el
More file actions
2413 lines (2219 loc) · 122 KB
/
Copy pathsupertag-core-persistence.el
File metadata and controls
2413 lines (2219 loc) · 122 KB
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
;;; supertag-core-persistence.el --- Data persistence for Supertag -*- lexical-binding: t; -*-
;;; Commentary:
;; This file provides functions for persisting the Supertag
;; in-memory store to a file and loading it back.
;;; Code:
(require 'cl-lib)
(require 'ht)
(require 'json) ; For presence-file encode/decode
(require 'parse-time) ; For parse-iso8601-time-string, used by presence
(require 'supertag-core-notify) ; For supertag-subscribe and supertag-emit-event
(require 'supertag-core-store) ; For supertag--store
(require 'supertag-core-index) ; For derived index rebuild after load
(require 'supertag-core-transform) ; For supertag-with-transaction (real per-entity rollback)
;;; --- Persistence Configuration ---
;; Note: supertag-data-directory is defined in supertag.el
;; This is a fallback definition in case this module is loaded independently
(defvar supertag-data-directory
(expand-file-name "supertag/" user-emacs-directory)
"Directory for storing Supertag data.
This is a fallback definition. The primary definition is in supertag.el.")
(defvar supertag--config-guard-allow)
(defconst supertag-data-version "6.0.0"
"Current data format version.
Used for data format compatibility checks and automatic migration.
Bumped 5.0.0 -> 6.0.0 (P1-8, see .phrase/phases/phase-git-sync-20260713/PLAN.md
\"S2 规范化序列化\", 修订 2026-07-13): the S2 canonical, line-per-entity
serialization is NOT actually readable by pre-6.0 (<= 5.9.x) builds the way
the original S2 writeup assumed. Those builds' `supertag--persistence--try-read-store'
does exactly ONE `read' of the file and returns whatever single form that
call happens to consume; against the canonical format's line-per-entity
layout, that first `read' only ever sees the root scalar line (e.g.
`(:version \"6.0.0\" ...)') and never reaches any of the following
`(:collection ...)' entity lines -- so an old build loads what LOOKS like a
valid, merely-empty store, not a parse error. Bumping the data version at
least makes `supertag--maybe-auto-migrate' fire (with its own pre-migration
snapshot) the first time a pre-6.0 database is loaded by THIS (>= 6.0)
build, and keeps `supertag--get-data-version'/`supertag--run-migrations'
honest about the fact that the format actually changed here. See
`supertag--persistence--write-canonical-store' for the belt-and-suspenders
`supertag-db-preformat6-*' snapshot, which covers the case this version
bump alone does not: a database already stamped `:version \"6.0.0\"' (or
any version equal to `supertag-data-version') by a subsequent save, so
`supertag--maybe-auto-migrate' sees no version mismatch and never runs,
yet the on-disk file might still be the pre-canonical (legacy single-`prin1')
format if it was never resaved since upgrading this package.")
(defun supertag-data-file (filename)
"Get full path for data file.
FILENAME is relative to `supertag-data-directory`."
(expand-file-name filename supertag-data-directory))
(defun supertag-persistence-check-legacy-data-directory ()
"Refuse ambiguous default data roots left by the breaking rename.
The check applies only when `supertag-data-directory' is the new default.
It never moves or deletes data. Return t when initialization may continue."
(let* ((configured (file-name-as-directory
(expand-file-name supertag-data-directory)))
(current (file-name-as-directory
(expand-file-name "supertag" user-emacs-directory)))
(legacy (file-name-as-directory
(expand-file-name "org-supertag" user-emacs-directory)))
(current-exists (file-exists-p current))
(legacy-exists (file-exists-p legacy)))
(when (string= configured current)
(cond
((and current-exists legacy-exists)
(user-error
"Supertag found both %s and %s; refusing to choose a data directory. Consolidate them or set `supertag-data-directory' explicitly"
(abbreviate-file-name legacy)
(abbreviate-file-name current)))
(legacy-exists
(user-error
"Supertag found the retired data directory %s. Quit other Emacs instances, back it up, and rename it to %s before starting Supertag"
(abbreviate-file-name legacy)
(abbreviate-file-name current)))))
t))
(defcustom supertag-db-file
(supertag-data-file "supertag-db.el")
"Database file path."
:type 'file
:group 'supertag)
(defcustom supertag-db-backup-directory
(supertag-data-file "backups")
"Directory for database backups."
:type 'directory
:group 'supertag)
(defcustom supertag-db-auto-save-interval 300
"Auto-save interval in seconds.
Set to nil to disable auto-save."
:type '(choice (const :tag "Disable" nil)
(integer :tag "Interval (seconds)"))
:group 'supertag)
(defcustom supertag-db-backup-interval 86400
"Daily backup interval in seconds (default: 24 hours).
Set to nil to disable daily backups."
:type '(choice (const :tag "Disable" nil)
(integer :tag "Interval (seconds)"))
:group 'supertag)
(defcustom supertag-db-backup-keep-days 3
"Number of days to keep daily backups.
Older backups will be automatically cleaned up."
:type 'integer
:group 'supertag)
(defcustom supertag-db-verify-after-save t
"When non-nil, verify the database file after saving.
The freshly written file is re-read and every durable collection declared
by `supertag--store-collections' is compared with the in-memory Store before
the previous database file is replaced. On mismatch or read error, the write
is aborted and the previous database file is left untouched."
:type 'boolean
:group 'supertag)
(defcustom supertag-db-lock t
"When non-nil, protect the database from concurrent multi-instance access.
Uses Emacs' built-in advisory file locking (`lock-file', `unlock-file',
`file-locked-p') for `supertag-db-file'. By default, local database files use
`supertag-db-lock-directory' so the lock stays on this host instead of a
network or sync filesystem. When another Emacs instance already holds the
lock, this session records the conflict in
`supertag--db-lock-conflict' and refuses to save the database until the
other instance releases the lock (or `supertag-db-retry-lock' is used once
it has exited)."
:type 'boolean
:group 'supertag)
(defcustom supertag-db-lock-directory
(expand-file-name "supertag-locks/" temporary-file-directory)
"Directory for local database advisory lock files.
When non-nil, local `supertag-db-file' paths are mapped to deterministic
SHA-256 lock names in this directory. This keeps same-host locking out of
network/sync folders, where stale lock artifacts can survive a disconnected
session. Remote/TRAMP database paths and a nil value retain Emacs' native
database-adjacent lock behavior. If this directory cannot be created, the
native behavior is used as a safe fallback."
:type '(choice (const :tag "Use database directory" nil) directory)
:group 'supertag)
(defcustom supertag-db-auto-migrate t
"When non-nil, automatically migrate an out-of-date database after load.
After `supertag-load-store' successfully loads `supertag-db-file', if the
loaded store's :version does not match `supertag-data-version', this session
runs `supertag-db-migrate-and-normalize' automatically instead of requiring
the user to invoke it by hand (see `supertag--maybe-auto-migrate').
A timestamped pre-migration snapshot of the database file is written to
`supertag-db-backup-directory' before migrating. When nil, out-of-date
databases are left as-is after loading; migrate manually with
\\[supertag-db-migrate-and-normalize]."
:type 'boolean
:group 'supertag)
(defcustom supertag-presence-enable t
"When non-nil, write and check an advisory presence file for cross-machine
awareness.
Supertag's database is a single serialized file. Users who sync it via
Dropbox/iCloud/etc. get that sync service's \"whole file, last writer wins,
no warning\" semantics — running Emacs against the same synced database on
two machines at once can silently discard one side's edits. This is NOT a
lock (a sync service's minutes-scale propagation delay means it cannot
physically be one); it is a best-effort, advisory heads-up: a small JSON
file recording which host last touched the database, and when, is written
next to `supertag-db-file' on load and periodically while this session
runs. When another host's presence looks recently active, loading warns
loudly. See README \"Syncing across machines\" for the supported
single-writer workflow this is meant to nudge users toward."
:type 'boolean
:group 'supertag)
(defcustom supertag-presence-stale-seconds 300
"Age in seconds beyond which a foreign presence record is ignored.
A presence record written by another host more than this many seconds ago
is treated as stale — that machine is presumed no longer actively editing —
and `supertag--presence-foreign-active-p' returns nil for it."
:type 'integer
:group 'supertag)
(defvar supertag-db--auto-save-timer nil
"Timer for auto-save.")
(defvar supertag-db--backup-timer nil
"Timer for daily backup.")
(defvar supertag-db--dirty nil
"Flag indicating if database has unsaved changes.")
(defvar supertag-db--last-backup-date nil
"Date of last backup in YYYY-MM-DD format.")
(defvar supertag--store-origin nil
"Metadata about the loaded store and its originating persistence state.")
(defvar supertag-persistence-after-save-hook nil
"Normal hook run after `supertag-save-store' successfully writes a
non-empty, actually-dirty store to disk (i.e. after the atomic write, the
dirty flag has been cleared, and the daily-backup check has run -- NOT on
every timer tick, and NOT when a persistence guard skipped or refused the
save). This is the S4 git-sync-mode commit trigger's hook point (see
`supertag-git.el', \"supertag-git-sync-mode\" -> commit debounce): it is
the one clean seam that fires exactly when `supertag-db-file' just changed
on disk, regardless of whether the save was triggered by the auto-save
timer, an explicit \\[supertag-save-store], or `kill-emacs-hook'. Functions
on this hook take no arguments and must not signal (an error here would
otherwise interrupt whatever just successfully saved the database).")
(defvar supertag-persistence-after-load-hook nil
"Normal hook run after `supertag-load-store' successfully loads a store
from disk (the branch that sets `supertag--store' from a readable file and
acquires the lock/presence claim -- NOT the fresh-empty-store branch, and
NOT a failed/corrupt-file load). Symmetric to
`supertag-persistence-after-save-hook' and meant for the same purpose:
letting an optional module react to a persistence lifecycle event without
this file requiring that module back (avoiding load-order coupling). Its
first user is `supertag-conflicts.el', which hooks in here (from its own
file, at its own load time -- this file never requires or knows about
that one) to message the user once when the just-loaded store carries
recorded `:sync-conflicts' (e.g. left behind by a git merge). Functions on
this hook take no arguments and must not signal.")
(defvar supertag--db-lock-conflict nil
"Non-nil when another Emacs instance holds the DB lock.
Holds the owner description string returned by `file-locked-p' (for example
\"user@host.12345:1698765432\") for whichever file `supertag--db-acquire-lock'
last checked. While non-nil, this session refuses to save the database (see
`supertag--persistence-guard-violations'). Cleared automatically once the
lock is acquired or the other instance's lock is found to be gone.")
(defvar supertag--db-locked-file nil
"File path this Emacs instance currently holds the advisory lock for, or nil.
Tracked separately from `supertag-db-file' so that switching vaults (which
reassigns `supertag-db-file' before the old lock is released) still releases
the correct file's lock.")
;;; --- Multi-instance DB Locking ---
(defun supertag--db-lock-file-transforms (file)
"Return the local lock transform for FILE, or nil when unavailable.
Only local paths are transformed; a failed directory creation deliberately
falls back to Emacs' native database-adjacent lock behavior."
(when (and (stringp supertag-db-lock-directory)
(> (length supertag-db-lock-directory) 0)
(stringp file)
(not (file-remote-p supertag-db-lock-directory))
(not (file-remote-p file)))
(let ((directory (file-name-as-directory
(expand-file-name supertag-db-lock-directory))))
(when (condition-case nil
(progn (make-directory directory t) t)
(error nil))
(list (list (concat "\\`" (regexp-quote (expand-file-name file)) "\\'")
directory
'sha256))))))
(defun supertag--db-lock-status (file)
"Return Emacs' lock status for FILE using SuperTag's lock location."
(let ((lock-file-name-transforms
(append (supertag--db-lock-file-transforms file)
lock-file-name-transforms)))
(file-locked-p file)))
(defun supertag--db-lock-file-name (file)
"Return the actual lock path Emacs uses for FILE."
(let ((lock-file-name-transforms
(append (supertag--db-lock-file-transforms file)
lock-file-name-transforms)))
(make-lock-file-name file)))
(defun supertag--db-acquire-lock ()
"Acquire the advisory lock on `supertag-db-file' for this Emacs instance.
When `supertag-db-lock' is enabled and `supertag-db-file' is set, checks
`file-locked-p' on it: if another Emacs instance already holds the lock
\(i.e. `file-locked-p' returns a string, not t), records the owner in
`supertag--db-lock-conflict' and warns that this session will not save the
database until the conflict clears. Otherwise, clears any previous conflict
and calls `lock-file' — locally binding `create-lockfiles' to t, since
`lock-file' is a no-op when that variable is nil. Any error signaled while
locking is caught and reported via `message' but never propagated, so a
locking problem can never break DB loading."
(when (and supertag-db-lock
(stringp supertag-db-file)
(> (length supertag-db-file) 0))
(let ((owner (supertag--db-lock-status supertag-db-file)))
(if (stringp owner)
(progn
(setq supertag--db-lock-conflict owner)
(message "Supertag: database %s is locked by another Emacs instance (%s); this session will NOT save until the lock is released. Run M-x supertag-db-retry-lock once the other instance has exited."
(abbreviate-file-name supertag-db-file) owner))
(setq supertag--db-lock-conflict nil)
(condition-case err
(let ((create-lockfiles t))
(let ((lock-file-name-transforms
(append (supertag--db-lock-file-transforms supertag-db-file)
lock-file-name-transforms)))
(lock-file supertag-db-file))
(setq supertag--db-locked-file supertag-db-file))
(error
(message "Supertag: failed to acquire lock on %s: %s (continuing without a lock)"
(abbreviate-file-name supertag-db-file)
(error-message-string err))))))))
(defun supertag--db-release-lock ()
"Release the advisory DB lock held by this Emacs instance, if any.
Safe no-op when no lock is currently held (`supertag--db-locked-file' is
nil). Any error from `unlock-file' is ignored, since a failed unlock must
never interrupt shutdown or vault switching."
(when supertag--db-locked-file
(ignore-errors
(let ((lock-file-name-transforms
(append (supertag--db-lock-file-transforms supertag--db-locked-file)
lock-file-name-transforms)))
(unlock-file supertag--db-locked-file)))
(setq supertag--db-locked-file nil))
(setq supertag--db-lock-conflict nil))
(defun supertag-db-retry-lock ()
"Retry acquiring the DB lock after a previously detected conflict.
Useful once the other Emacs instance holding the lock on `supertag-db-file'
has exited: re-checks `file-locked-p' and, if the lock is now free (or
already held by this instance), calls `supertag--db-acquire-lock' to take
it over so saves can resume."
(interactive)
(supertag--db-acquire-lock)
(if supertag--db-lock-conflict
(message "Supertag: database %s is still locked by another Emacs instance (%s)."
(abbreviate-file-name supertag-db-file) supertag--db-lock-conflict)
(message "Supertag: database lock acquired for %s."
(abbreviate-file-name supertag-db-file))))
;;; --- Cross-machine Presence (advisory; S0 of the git-sync hardening plan) ---
;;
;; `supertag--db-acquire-lock' above only ever sees *this machine's* other
;; Emacs instances (`lock-file' writes a host-local symlink, using the
;; configured transform above for local databases). It cannot detect a second
;; machine editing the same Dropbox/iCloud-synced database. Presence closes
;; that visibility gap with
;; an ordinary, sync-friendly JSON file instead of a lock primitive: it is
;; written periodically and read on load, purely advisory, and never blocks
;; a save the way a lock conflict does.
(defvar supertag--presence-write-failed nil
"Non-nil once a presence-file write has failed and been warned about.
Keeps `supertag--presence-write' from spamming a `display-warning' on every
auto-save timer tick after the first failure — the underlying condition
(for example an unwritable data directory) is unlikely to resolve itself
between ticks, so warn once per session and go quiet.")
(defun supertag--presence-file ()
"Return the path of the advisory cross-machine presence file, or nil.
The file lives NEXT TO `supertag-db-file' (same directory) rather than in a
dedicated state directory, because that shared directory is exactly what
sync services like Dropbox/iCloud propagate for the users this feature is
for. For local-only users, an extra small file there is harmless.
Returns nil when `supertag-db-file' is unset or empty."
(when (and (stringp supertag-db-file) (> (length supertag-db-file) 0))
(expand-file-name "supertag-presence.json"
(file-name-directory supertag-db-file))))
(defun supertag--presence-write ()
"Best-effort, atomic write of this session's presence claim.
Writes `{\"host\": ..., \"updatedAt\": ..., \"pid\": ...}' (via
`json-encode') to `supertag--presence-file', using the same temp-file +
`rename-file' pattern as `supertag--persistence-write-store-atomically' so a
concurrent reader never observes a half-written file. `updatedAt' is an
ISO 8601 UTC timestamp.
Never signals: `supertag-presence-enable' nil is a no-op, a nil
`supertag--presence-file' (unset `supertag-db-file') is a no-op, and any
other error is caught and reported via `display-warning' at most once per
session (see `supertag--presence-write-failed') rather than propagated —
a presence-file problem must never break a save or a load."
(when supertag-presence-enable
(condition-case err
(let ((file (supertag--presence-file)))
(when file
(let* ((dir (file-name-directory file))
(payload (json-encode
(list (cons 'host (system-name))
(cons 'updatedAt
(format-time-string "%Y-%m-%dT%H:%M:%SZ"
nil t))
(cons 'pid (emacs-pid)))))
(temp-file (make-temp-file (concat file ".tmp")))
(success nil))
(unless (file-exists-p dir)
(make-directory dir t))
(unwind-protect
(progn
(with-temp-buffer
(set-buffer-file-coding-system 'utf-8-unix)
(insert payload)
(write-region (point-min) (point-max) temp-file nil 'silent))
(rename-file temp-file file t)
(setq success t))
(unless success
(ignore-errors (delete-file temp-file)))))))
(error
(unless supertag--presence-write-failed
(setq supertag--presence-write-failed t)
(display-warning
'supertag
(format "Supertag: failed to write cross-machine presence file: %s"
(error-message-string err))
:warning))))))
(defun supertag--presence-read ()
"Return the parsed presence file as an alist, or nil on any error.
Parses `supertag--presence-file' via `json-read-file'. Returns nil when
`supertag-db-file' is unset, the presence file does not exist, or it fails
to parse as JSON — callers must treat nil as \"no usable presence
information\", never as an error."
(let ((file (supertag--presence-file)))
(when (and file (file-exists-p file))
(condition-case nil
(json-read-file file)
(error nil)))))
(defun supertag--presence-foreign-active-p ()
"Return the foreign host string when another machine's presence is active.
Non-nil only when all of the following hold: the presence file exists and
parses, its recorded `host' differs from `(system-name)', its `updatedAt'
parses as ISO 8601 (via `parse-iso8601-time-string'), and that timestamp is
within `supertag-presence-stale-seconds' of now. Returns nil when the file
is missing/unparseable, records this host, or is stale (older than the
threshold)."
(let* ((data (supertag--presence-read))
(host (and data (cdr (assq 'host data))))
(updated-at (and data (cdr (assq 'updatedAt data)))))
(when (and (stringp host)
(not (string= host (system-name)))
(stringp updated-at))
(let ((parsed (ignore-errors (parse-iso8601-time-string updated-at))))
(when parsed
(let ((age (float-time (time-subtract (current-time) parsed))))
(when (< age supertag-presence-stale-seconds)
host)))))))
(defun supertag--presence-check-and-claim ()
"Warn about a recently-active foreign presence, then claim this host's.
Meant to run right after a successful `supertag-load-store'. When
`supertag--presence-foreign-active-p' reports another host was recently
active on this database, shows a loud, actionable `display-warning' (not a
`message': a status-bar message is too easy to miss at exactly the moment
this matters, right after opening a database another machine may still be
writing to). Afterwards — whether or not a warning was shown — writes this
session's own presence via `supertag--presence-write', claiming the
database for this host going forward."
(when supertag-presence-enable
(let ((foreign-host (supertag--presence-foreign-active-p)))
(when foreign-host
(let* ((data (supertag--presence-read))
(updated-at (and data (cdr (assq 'updatedAt data))))
(parsed (and (stringp updated-at)
(ignore-errors (parse-iso8601-time-string updated-at))))
(age (and parsed (round (float-time (time-subtract (current-time) parsed))))))
(display-warning
'supertag
(format "SUPERTAG: ANOTHER MACHINE MAY STILL BE EDITING THIS DATABASE.
Host %s was active on this database %s ago (%s).
This database file has no merge support: if you keep editing on both
machines at the same time, whichever one saves LAST WINS and the other
machine's changes are silently discarded — there will be no error, no
conflict marker, just quietly lost work.
If you are done editing on %s, this warning is safe to ignore.
Otherwise, quit Emacs there before continuing to edit here.
See README \"Syncing across machines\" for the supported workflow."
foreign-host
(if age (format "%d second%s" age (if (= age 1) "" "s")) "recently")
(abbreviate-file-name supertag-db-file)
foreign-host)
:warning))))
(supertag--presence-write)))
(defun supertag--presence-release ()
"Best-effort delete of this host's own presence claim.
Meant to run on `kill-emacs-hook'. Deletes `supertag--presence-file' ONLY
when it still names this host (`(system-name)') — if another, newer machine
has since overwritten it with its own claim, that claim is left alone,
since deleting it would erase real presence information that other host's
own load-time check depends on. Any error is ignored: a failed delete must
never interrupt shutdown."
(when supertag-presence-enable
(ignore-errors
(let* ((file (supertag--presence-file))
(data (and file (file-exists-p file) (supertag--presence-read)))
(host (and data (cdr (assq 'host data)))))
(when (and file (stringp host) (string= host (system-name)))
(delete-file file))))))
;;; --- Backup Functions ---
(defun supertag-get-backup-filename (date-str)
"Generate backup filename for given DATE-STR in YYYY-MM-DD format."
(expand-file-name
(format "supertag-db-%s.el" date-str)
supertag-db-backup-directory))
(defun supertag-create-daily-backup ()
"Create a daily backup of the database if needed.
Returns t if backup was created, nil if not needed."
(let* ((today (format-time-string "%Y-%m-%d"))
(backup-file (supertag-get-backup-filename today)))
(if (file-exists-p backup-file)
nil
(when (file-exists-p supertag-db-file)
(supertag-persistence-ensure-data-directory)
(copy-file supertag-db-file backup-file)
(setq supertag-db--last-backup-date today)
(message "Daily backup created: %s" backup-file)
t))))
(defun supertag-cleanup-old-backups ()
"Remove backup files older than `supertag-db-backup-keep-days` days."
(when (file-exists-p supertag-db-backup-directory)
(let* ((cutoff-time (time-subtract (current-time)
(days-to-time supertag-db-backup-keep-days)))
(backup-files (directory-files supertag-db-backup-directory t
"^supertag-db-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\.el$"))
(removed-count 0))
(dolist (backup-file backup-files)
(let ((file-time (nth 5 (file-attributes backup-file))))
(when (time-less-p file-time cutoff-time)
(delete-file backup-file)
(cl-incf removed-count)
(message "Removed old backup: %s" backup-file))))
(when (> removed-count 0)
(message "Cleaned up %d old backup files" removed-count)))))
(defun supertag-backup-database-now ()
"Force create a backup immediately and clean up old backups.
This function can be called interactively by users."
(interactive)
(supertag-create-daily-backup)
(supertag-cleanup-old-backups))
(defun supertag-check-daily-backup ()
"Check if daily backup is needed and create one if necessary."
(let ((today (format-time-string "%Y-%m-%d")))
(unless (string= today supertag-db--last-backup-date)
(when (supertag-create-daily-backup)
(supertag-cleanup-old-backups)))))
;;; --- Persistence Functions ---
(defun supertag-mark-dirty ()
"Mark database as having unsaved changes."
(setq supertag-db--dirty t))
(defun supertag-clear-dirty ()
"Clear database unsaved changes flag."
(setq supertag-db--dirty nil))
(defun supertag-dirty-p ()
"Check if database has unsaved changes."
supertag-db--dirty)
(defun supertag--count-nodes ()
"Return the number of node entries in the store."
(let ((nodes-table (supertag-store-get-collection :nodes)))
(if (hash-table-p nodes-table)
(hash-table-count nodes-table)
0)))
(defun supertag--count-field-values ()
"Return the total number of field values stored."
(let ((root (supertag-store-get-collection :field-values))
(count 0))
(when (hash-table-p root)
(maphash
(lambda (_node-id node-table)
(when (hash-table-p node-table)
(cl-incf count (hash-table-count node-table))))
root))
count))
(defun supertag--persistence--normalize-path (path)
"Normalize PATH for comparison, or nil when PATH is invalid."
(when (and (stringp path) (> (length path) 0))
(expand-file-name path)))
(defun supertag--persistence--expected-sync-state-file ()
"Return expected sync-state path derived from current data directory."
(when (and (boundp 'supertag-data-directory)
(stringp supertag-data-directory)
(> (length supertag-data-directory) 0))
(expand-file-name "sync-state.el"
(file-name-as-directory
(expand-file-name supertag-data-directory)))))
(defun supertag--persistence--data-dir ()
"Return normalized `supertag-data-directory`, or nil when unset."
(when (and (boundp 'supertag-data-directory)
(stringp supertag-data-directory)
(> (length supertag-data-directory) 0))
(file-name-as-directory
(expand-file-name supertag-data-directory))))
(defun supertag--persistence--default-db-file ()
"Return the default DB file path derived from `supertag-data-directory`."
(let ((dir (supertag--persistence--data-dir)))
(when dir
(expand-file-name "supertag-db.el" dir))))
(defun supertag--persistence--newest-db-snapshot (&optional dir)
"Return newest DB snapshot file under DIR (or `supertag-data-directory`).
This is a best-effort fallback for legacy filenames like `supertag-db-YYYY-MM-DD.el`
or files with a `.db` extension that still contain an Emacs-lisp printed store."
(let* ((dir (or dir (supertag--persistence--data-dir))))
(when (and dir (file-directory-p dir))
(let* ((candidates (directory-files dir t "^supertag-db-.*\\.\\(el\\|db\\)$" t))
(dated (delq nil
(mapcar (lambda (path)
(let ((attrs (ignore-errors (file-attributes path))))
(when attrs
(cons path (nth 5 attrs)))))
candidates)))
(sorted (sort dated (lambda (a b) (time-less-p (cdr b) (cdr a))))))
(car (car sorted))))))
(defun supertag--persistence--db-file-candidates (&optional file)
"Return a de-duplicated list of DB file candidates for FILE/current config."
(let* ((explicit (supertag--persistence--normalize-path file))
(configured (supertag--persistence--normalize-path supertag-db-file))
(default (supertag--persistence--default-db-file))
(legacy (let ((dir (supertag--persistence--data-dir)))
(when dir
(expand-file-name "supertag-db.db" dir))))
(snapshot (supertag--persistence--newest-db-snapshot)))
(cl-delete-duplicates (delq nil (list explicit configured default legacy snapshot))
:test #'string=
:from-end t)))
(defun supertag--persistence--pick-readable-file (paths)
"Return the first readable regular file in PATHS, or nil."
(cl-loop for path in paths
for expanded = (and (stringp path)
(> (length path) 0)
(ignore-errors (expand-file-name path)))
when (and expanded
(file-exists-p expanded)
(not (file-directory-p expanded))
(file-readable-p expanded))
return expanded))
(defun supertag--persistence--set-db-file (path)
"Set `supertag-db-file` to PATH, respecting config guard when available."
(when (and (stringp path) (> (length path) 0))
(let ((supertag--config-guard-allow t))
(setq supertag-db-file path))))
;;; --- S2: Canonical, deterministic, line-per-entity serialization ---
;;
;; Design goal (see .phrase/phases/phase-git-sync-20260713/PLAN.md "S2 规范化
;; 序列化"): same logical store content must produce byte-identical output on
;; any machine, and a single-field change on one entity must show up as a
;; single-line `git diff'. This is a hard prerequisite for S3's git merge
;; driver, which parses this exact line format.
;;
;; FORMAT (frozen once tests pass — S3 depends on it):
;;
;; ;; -*- mode: lisp-data; coding: utf-8-unix -*-
;; ;; supertag-db canonical format 1
;; (:version "5.0.0") <- root scalars, one line
;; (:collection :nodes :id "node-a" :data (...)) <- one line per entity
;; (:collection :nodes :id "node-b" :data (...))
;; (:collection :tags :id "tag-x" :data (...))
;;
;; - Root keys of the store are split into "collections" (hash-table valued
;; — printed one entity per line, collections ordered alphabetically by
;; keyword name, entities within a collection ordered by `string<' of their
;; id's string form) and "scalars" (everything else, e.g. :version —
;; merged into a single sorted plist line).
;; - Every plist appearing inside entity :data is printed with its keys
;; sorted alphabetically, RECURSIVELY into nested plists. Ordinary
;; (non-plist) lists are walked but never reordered — only their elements
;; are recursively canonicalized, preserving original element order.
;; - PRE-CHECK FINDING (hash tables nested in entity data): the store is NOT
;; uniformly plists-all-the-way-down. `supertag-store-get-collection
;; :fields' (legacy three-level node -> tag -> field nesting, see
;; `supertag--normalize-fields-collection' above) and `:field-values'
;; (two-level node -> field nesting, see `supertag-store-put-field-value'
;; in supertag-core-store.el) are populated by direct hash-table puts that
;; bypass `supertag-store-put-entity'/`supertag--normalize-entity', so the
;; ENTITY VALUE for those two collections is itself a hash table, not a
;; plist. Rather than assert "always a plist" and error on this (as a
;; naive first cut might), `supertag--persistence--canonicalize-value'
;; below handles hash tables found ANYWHERE in entity data generically: it
;; freezes them into a sorted, re-readable `(:supertag-hash-table ...)'
;; marker form (see that function and `supertag--persistence--thaw-value'
;; for the inverse). This also means no assumption is silently made about
;; which collections may contain nested hash tables — any hash table
;; anywhere in the tree is handled the same way.
;; - PRE-CHECK FINDING (cross-entity shared structure): a probe over the
;; 10k-node fixture in test/perf-benchmark.el found ~20,000 `eq'-shared
;; cons cells — but ALL of it traced back to that fixture building a
;; single `field-defs' list once and reusing the SAME object across all 50
;; synthetic tags' :fields slot (the fixture docstring says outright it
;; "bypass[es] the ops/commit layer entirely"). Real write paths
;; (e.g. `supertag-tag-create' in supertag-ops-tag.el) go through
;; `supertag--deep-copy-plist' specifically to avoid this kind of aliasing.
;; Regardless of which case applies, per-entity independent `prin1' (no
;; `print-circle' spanning multiple lines) makes cross-entity sharing a
;; pure non-issue for correctness: each entity's data is fully
;; materialized on its own line, so shared structure just gets printed
;; (and, after a load, held) as separate `equal'-but-not-`eq' copies. No
;; coercion-layer surgery was needed in supertag-core-store.el.
;; - `print-circle' is still bound around each per-line `prin1' call, which
;; protects against a genuinely self-referential value WITHIN one entity's
;; own data (distinct from cross-entity sharing above); Emacs builds a
;; fresh circular-reference table for every top-level `prin1' call, so
;; binding it once around the whole write (rather than re-binding inside
;; the loop) still gives each line independent, correct handling.
(defun supertag--persistence-canonical-format-header ()
"Return the two-line header written at the top of every canonical DB file.
The second line embeds the CURRENT `supertag-data-version' (computed at
call time, not baked into a `defconst', so it always reflects whatever
this build's version actually is) alongside the canonical format-generation
number -- see P1-8 / .phrase/phases/phase-git-sync-20260713/PLAN.md \"S2
规范化序列化\", 修订 2026-07-13. This is a `;'-comment, skipped by
`supertag--persistence--skip-leading-comments-and-whitespace' before any
`read', so it carries no parsing weight -- it exists purely so a human (or
a pre-6.0 build's `supertag-db-inspect-file', which still does its own raw
`read' + `hash-table-p' check on whatever the first form turns out to be)
has a fighting chance of noticing the format at a glance. The load-bearing
machine-readable sentinel is the root scalar line printed by
`supertag--persistence--write-canonical-store' below (`:supertag-format' /
`:incompatible-notice'), not this comment."
(format ";; -*- mode: lisp-data; coding: utf-8-unix -*-\n;; supertag-db canonical format 1, data version %s\n"
supertag-data-version))
(defconst supertag--persistence--hash-marker :supertag-hash-table
"Marker keyword identifying a frozen hash table in canonical output.
See `supertag--persistence--canonicalize-value' and
`supertag--persistence--thaw-value'.")
(defconst supertag--persistence-format-marker 1
"Canonical on-disk format generation number.
Distinct from `supertag-data-version' (the application-level data-shape
version): this tracks the S2 line-per-entity FILE FORMAT itself, which has
not changed since its introduction (git-sync S2) even though the data
version has been bumped (P1-8). Embedded, unconditionally, into every
canonical save's root scalar line via `:supertag-format' -- see
`supertag--persistence--write-canonical-store'.")
(defconst supertag--persistence-incompatible-notice
"This DB uses org-supertag >= 6.0 canonical format. An org-supertag < 6.0 (e.g. 5.9.x) session reads only this single header form as its ENTIRE database via one `read' call and will show zero nodes/tags -- your data is NOT lost, it is still in this file below this line, but do not keep editing or saving from that old session. To downgrade, restore the newest backups/supertag-db-preformat6-*.el snapshot over this file (see supertag--persistence--write-store-atomically / README \"Syncing across machines\")."
"Sentinel text embedded, verbatim, into every canonical save's root scalar
line under the `:incompatible-notice' key (P1-8). Deliberately kept on a
SINGLE physical line (no embedded newlines): the canonical writer does not
bind `print-escape-newlines', so a literal newline inside this string would
print as an actual line break and break the \"one line per top-level form\"
property the whole S2 format depends on for clean `git diff' output and
for `supertag--persistence--read-canonical-forms' treating each buffer
line as one `read'. This cannot retroactively fix a pre-6.0 reader (which
never gets far enough to see this key at all -- its one `read' call
returns before this string would even be reached if it did), but it
means ANY code path that DOES surface the raw first form of a >= 6.0
canonical file (an old build's `supertag-db-inspect-file', `find-file' +
manual `read', etc.) has a chance of showing the user something
actionable instead of an inert, silently-empty-looking plist.")
(defun supertag--persistence--sort-key (key)
"Return a string used to order KEY (an entity id or hash-table key).
Coerces KEY to a string form equivalent to `format \"%s\"' so ids/keys of
different Lisp types (strings, keywords, numbers, symbols) still sort
consistently and deterministically against each other. Special-cased for
the two overwhelmingly common cases — a plain string (most entity ids)
returned as-is, and a symbol/keyword (all plist keys) via `symbol-name'
— to skip `format''s general parsing machinery, since this runs on every
entity id and every plist key in the whole store."
(cond
((stringp key) key)
((symbolp key) (symbol-name key))
(t (format "%s" key))))
(defun supertag--persistence--sort-pairs-by-key (pairs)
"Return PAIRS (each a cons `(KEY . VALUE)') sorted by KEY's sort key.
A Schwartzian transform: `supertag--persistence--sort-key' is computed
exactly ONCE per pair up front, rather than repeatedly inside the sort
comparator. This matters at the DB's real scale — computing it inside the
comparator costs O(n log n) `format' calls (one measured run over 5k
entities alone made the canonical writer ~14x slower than a plain
`prin1' dump, almost entirely `format' overhead in comparators; this
transform brought it back under the 2x perf-guard budget in
test/canonical-serialization-test.el)."
(mapcar #'cdr
(sort (mapcar (lambda (pair)
(cons (supertag--persistence--sort-key (car pair)) pair))
pairs)
(lambda (a b) (string< (car a) (car b))))))
(defun supertag--persistence--plist-p (value)
"Conservatively detect whether VALUE looks like a plist.
Returns non-nil only for a proper, non-empty, even-length list whose
element at every even (0-based) index is a keyword. This deliberately
excludes nil/empty lists, improper (dotted) lists, and lists of
non-keyword-prefixed items — e.g. `:tag-field-associations' values (an
ordinary list of association plists) do not themselves satisfy this
predicate, so they are walked element-by-element but never key-sorted or
reordered as a whole.
This predicate alone is used only by callers outside the hot
canonicalization path (e.g. the canonical-format reader, which calls it
at most once per line); `supertag--persistence--canonicalize-value' below
uses the fused `supertag--persistence--plist-pairs-or-nil' instead, which
detects AND collects in one pass — see that function's docstring for why."
(let ((len (proper-list-p value)))
(and len
(> len 0)
(cl-evenp len)
(cl-loop for cell on value by #'cddr
always (keywordp (car cell))))))
(defun supertag--persistence--plist-pairs-or-nil (value)
"Return VALUE's `(SORT-KEY KEY . RAW-VALUE)' triples if it is a plist, else nil.
A fused, single-pass replacement for calling
`supertag--persistence--plist-p' (itself a full traversal) and THEN
separately collecting key/value pairs (a second full traversal): this
walks VALUE exactly once, bailing out immediately on the first sign it
is not a plist (an odd remaining length, or a non-keyword key), and
precomputes each key's `supertag--persistence--sort-key' along the way so
the caller's `sort' never needs to recompute it. This is on the hottest
path in the file — `supertag--persistence--canonicalize-value' runs it on
every cons it visits — and collapsing several O(n) passes into one made a
measurable difference on the 5k-node perf-guard benchmark in
test/canonical-serialization-test.el (originally ~14x the cost of a
plain `prin1' dump; the budget is 2x)."
(let ((cursor value) (pairs nil) (ok t))
(while (and ok (consp cursor))
(let ((k (car cursor)))
(if (and (keywordp k) (consp (cdr cursor)))
(progn
(push (cons (supertag--persistence--sort-key k) (cons k (cadr cursor))) pairs)
(setq cursor (cddr cursor)))
(setq ok nil))))
(and ok (null cursor) pairs)))
(defsubst supertag--persistence--canonicalize-maybe-atom (value)
"Like `supertag--persistence--canonicalize-value', but inlined and with a
fast exit for anything that cannot possibly need canonicalizing: only
conses, hash tables, and vectors are ever restructured, so the
overwhelmingly common case of a plain leaf value (string/number/keyword/
symbol/nil — most values in most plists) skips the real function call
entirely. Being a `defsubst', this check itself is inlined at every call
site rather than adding another call frame. This one change measurably
mattered on the 10k-node fixture used by the perf-guard test."
(if (or (consp value) (hash-table-p value) (vectorp value))
(supertag--persistence--canonicalize-value value)
value))
(defun supertag--persistence--rebuild-sorted-plist (pairs)
"Rebuild a flat, sorted, canonicalized plist from PAIRS.
PAIRS is the `(SORT-KEY KEY . RAW-VALUE)' triple list returned by
`supertag--persistence--plist-pairs-or-nil'.
Builds one small `(KEY VALUE)' list per pair and splices them together
with `nconc' (each pair still in its own freshly-consed 2-element list,
so this is safe) rather than a single shared `push'/`nreverse'
accumulator — pushing both KEY and VALUE onto one accumulator and
reversing once at the end does NOT recover the right order: reversing
the whole flat sequence also swaps each pair's internal key/value
positions, not just the pairs' relative order. (Caught by
test/canonical-serialization-test.el's sorted-invariants test.)"
(setq pairs (sort pairs (lambda (a b) (string< (car a) (car b)))))
(apply #'nconc
(mapcar (lambda (p)
(list (cadr p) (supertag--persistence--canonicalize-maybe-atom (cddr p))))
pairs)))
(defun supertag--persistence--freeze-hash-table (table)
"Return a canonical, sorted, re-readable form of hash table TABLE.
The result is `(:supertag-hash-table ((KEY . VALUE) ...))' with entries
sorted by `supertag--persistence--sort-key' on KEY and VALUE canonicalized
recursively. `supertag--persistence--thaw-value' reverses this back into an
actual (`equal'-test) hash table on load. Sorting a hash table's entries
for deterministic output is NOT the same thing as reordering an ordinary
list's elements (which `supertag--persistence--plist-p'/canonicalize-value
never do) — a hash table has no inherent element order to preserve in the
first place."
(let (pairs)
(maphash (lambda (k v) (push (cons (supertag--persistence--sort-key k) (cons k v)) pairs))
table)
(setq pairs (sort pairs (lambda (a b) (string< (car a) (car b)))))
(list supertag--persistence--hash-marker
(mapcar (lambda (p) (cons (cadr p) (supertag--persistence--canonicalize-maybe-atom (cddr p))))
pairs))))
(defun supertag--persistence--canonicalize-value (value)
"Return VALUE with nested hash tables frozen and plist keys sorted.
Recurses into plists (sorting keys), ordinary lists (preserving element
order), improper/dotted conses, and vectors. Atoms are returned unchanged.
See the commentary above `supertag--persistence-canonical-format-header'
for why this is safe with respect to cross-entity shared structure."
(cond
((hash-table-p value)
(supertag--persistence--freeze-hash-table value))
((consp value)
(let ((plist-pairs (supertag--persistence--plist-pairs-or-nil value)))
(if plist-pairs
(supertag--persistence--rebuild-sorted-plist plist-pairs)
(let ((len (proper-list-p value)))
(if len
(mapcar #'supertag--persistence--canonicalize-maybe-atom value)
(cons (supertag--persistence--canonicalize-maybe-atom (car value))
(supertag--persistence--canonicalize-maybe-atom (cdr value))))))))
((vectorp value)
(apply #'vector
(mapcar #'supertag--persistence--canonicalize-maybe-atom (append value nil))))
(t value)))
(defun supertag--persistence--empty-collection-p (value)
"Return non-nil when VALUE is a missing or empty Store collection."
(or (eq value supertag--not-found)
(and (hash-table-p value) (= 0 (hash-table-count value)))))
(defun supertag--persistence--collection-roundtrip-equal-p (left right collection)
"Return non-nil when COLLECTION has equal contents in LEFT and RIGHT.
Missing and empty collections are equivalent because the canonical writer
does not emit lines for an empty hash table."
(let ((left-table (if (hash-table-p left)
(gethash collection left supertag--not-found)
supertag--not-found))
(right-table (if (hash-table-p right)
(gethash collection right supertag--not-found)
supertag--not-found)))
(cond
((and (supertag--persistence--empty-collection-p left-table)
(supertag--persistence--empty-collection-p right-table))
t)
((and (hash-table-p left-table) (hash-table-p right-table)
(= (hash-table-count left-table) (hash-table-count right-table)))
(catch 'mismatch
(maphash
(lambda (id value)
(let ((other (gethash id right-table supertag--not-found)))
(when (or (eq other supertag--not-found)
(not (equal
(supertag--persistence--canonicalize-value value)
(supertag--persistence--canonicalize-value other))))
(throw 'mismatch nil))))
left-table)
t))
(t nil))))
(defun supertag--persistence--mismatched-durable-collections (left right)
"Return durable collections whose contents differ between LEFT and RIGHT."
(cl-loop for collection in supertag--store-collections
unless (supertag--persistence--collection-roundtrip-equal-p
left right collection)
collect collection))
(defun supertag--persistence--thaw-value (value)
"Inverse of `supertag--persistence--canonicalize-value'.
Rebuilds an `equal'-test hash table from any
`(:supertag-hash-table ((KEY . VALUE) ...))' marker found anywhere in
VALUE (at any nesting depth), recursing into plists/lists/vectors exactly
like the canonicalizer. Everything else is returned unchanged."
(cond
((and (consp value)
(eq (car value) supertag--persistence--hash-marker)
(consp (cdr value))
(null (cddr value)))
(let ((table (ht-create)))
(dolist (pair (cadr value))
(puthash (car pair) (supertag--persistence--thaw-value (cdr pair)) table))
table))
((null value) nil)
((vectorp value)
(apply #'vector (mapcar #'supertag--persistence--thaw-value (append value nil))))
((consp value)
(let ((len (proper-list-p value)))
(if len
(mapcar #'supertag--persistence--thaw-value value)
(cons (supertag--persistence--thaw-value (car value))
(supertag--persistence--thaw-value (cdr value))))))
(t value)))
(defun supertag--persistence--write-canonical-store (store buffer)
"Insert the canonical, deterministic serialization of STORE into BUFFER.
STORE must be a hash table (the shape `supertag--store' always has after
`supertag--ensure-store'). See the format commentary above
`supertag--persistence-canonical-format-header'.