@sproutboat/runtime 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/LTX_REPLICATION.md +19 -4
- package/package.json +1 -1
- package/src/kv-shim.test.js +124 -0
- package/src/native-fetch-prelude.js +377 -2
- package/src/transport-embedded.js +180 -18
- package/test/ltx-restore.ts +110 -0
- package/test/native-d1.ts +186 -19
- package/test/native-ltx.ts +60 -2
|
@@ -419,9 +419,26 @@ static int sb_ltx_begin(sb_ltx_writer *w, const char *path, sb_ltx_state *s, uin
|
|
|
419
419
|
memset(w, 0, sizeof(*w));
|
|
420
420
|
w->hash = UINT64_MAX;
|
|
421
421
|
w->page_size = s->page_size;
|
|
422
|
+
// Global temporary directories are outside a sandboxed worker's writable tree.
|
|
423
|
+
// After unlink, close (including process death) reclaims the scratch file.
|
|
424
|
+
char index_path[SB_LTX_PATH];
|
|
425
|
+
if (snprintf(index_path, sizeof(index_path), "%s/index-XXXXXX", s->root) >= sizeof(index_path))
|
|
426
|
+
return -1;
|
|
427
|
+
int index = mkstemp(index_path);
|
|
428
|
+
if (index < 0)
|
|
429
|
+
return -1;
|
|
430
|
+
sb_ltx_phase("index-created");
|
|
431
|
+
if (unlink(index_path)) {
|
|
432
|
+
close(index);
|
|
433
|
+
return -1;
|
|
434
|
+
}
|
|
435
|
+
w->index = fdopen(index, "w+b");
|
|
436
|
+
if (!w->index) {
|
|
437
|
+
close(index);
|
|
438
|
+
return -1;
|
|
439
|
+
}
|
|
422
440
|
w->out = fopen(path, "wbx");
|
|
423
|
-
w->
|
|
424
|
-
if (!w->out || !w->index)
|
|
441
|
+
if (!w->out)
|
|
425
442
|
return -1;
|
|
426
443
|
unsigned char h[100] = {0};
|
|
427
444
|
memcpy(h, "LTX1", 4);
|
|
@@ -1048,6 +1065,41 @@ done:
|
|
|
1048
1065
|
return 1;
|
|
1049
1066
|
}
|
|
1050
1067
|
|
|
1068
|
+
// The capture lock is already held. Remove only reserved, regular scratch files;
|
|
1069
|
+
// do not open them (a hard link could share the database's POSIX-locked inode).
|
|
1070
|
+
static int sb_ltx_sweep_indexes(const char *root) {
|
|
1071
|
+
DIR *dir = opendir(root);
|
|
1072
|
+
if (!dir)
|
|
1073
|
+
return -1;
|
|
1074
|
+
int rc = 0;
|
|
1075
|
+
for (;;) {
|
|
1076
|
+
errno = 0;
|
|
1077
|
+
struct dirent *entry = readdir(dir);
|
|
1078
|
+
if (!entry) {
|
|
1079
|
+
if (errno)
|
|
1080
|
+
rc = -1;
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
const char *name = entry->d_name;
|
|
1084
|
+
if (strlen(name) != 12 || strncmp(name, "index-", 6) ||
|
|
1085
|
+
strspn(name + 6, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") != 6)
|
|
1086
|
+
continue;
|
|
1087
|
+
struct stat st;
|
|
1088
|
+
if (fstatat(dirfd(dir), name, &st, AT_SYMLINK_NOFOLLOW)) {
|
|
1089
|
+
if (errno == ENOENT)
|
|
1090
|
+
continue;
|
|
1091
|
+
rc = -1;
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
if (S_ISREG(st.st_mode) && unlinkat(dirfd(dir), name, 0) && errno != ENOENT) {
|
|
1095
|
+
rc = -1;
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (closedir(dir))
|
|
1100
|
+
rc = -1;
|
|
1101
|
+
return rc;
|
|
1102
|
+
}
|
|
1051
1103
|
static int sb_ltx_start(int idx, sqlite3 *db, const char *path, int lock) {
|
|
1052
1104
|
sb_ltx_state *s = &sb_ltx_states[idx];
|
|
1053
1105
|
memset(s, 0, sizeof(*s));
|
|
@@ -1059,6 +1111,8 @@ static int sb_ltx_start(int idx, sqlite3 *db, const char *path, int lock) {
|
|
|
1059
1111
|
return -1;
|
|
1060
1112
|
if (mkdir(s->root, 0700) && errno != EEXIST)
|
|
1061
1113
|
return -1;
|
|
1114
|
+
if (sb_ltx_sweep_indexes(s->root))
|
|
1115
|
+
return -1;
|
|
1062
1116
|
char parent[SB_LTX_PATH];
|
|
1063
1117
|
snprintf(parent, sizeof(parent), "%s", path);
|
|
1064
1118
|
char *slash = strrchr(parent, '/');
|
|
@@ -2137,12 +2191,12 @@ static int sb_db_page_size(const char* path) {
|
|
|
2137
2191
|
if (st) sqlite3_finalize(st);
|
|
2138
2192
|
return size;
|
|
2139
2193
|
}
|
|
2140
|
-
// Freeze (refuse writes to) or thaw a database for the quota. Returns 1
|
|
2141
|
-
// is under LTX capture, 0 when not, -1 when it cannot be opened.
|
|
2194
|
+
// Freeze (refuse writes to) or thaw a database for the quota; on < 0 only asks. Returns 1
|
|
2195
|
+
// when the database is under LTX capture, 0 when not, -1 when it cannot be opened.
|
|
2142
2196
|
static int sb_db_quota_freeze(const char* path, int on) {
|
|
2143
2197
|
int idx = sb_db_for(path);
|
|
2144
2198
|
if (idx < 0) return -1;
|
|
2145
|
-
sb_quota_frozen[idx] = on && sb_ltx_states[idx].db ? 1 : 0;
|
|
2199
|
+
if (on >= 0) sb_quota_frozen[idx] = on && sb_ltx_states[idx].db ? 1 : 0;
|
|
2146
2200
|
return sb_ltx_states[idx].db ? 1 : 0;
|
|
2147
2201
|
}
|
|
2148
2202
|
|
|
@@ -3197,7 +3251,7 @@ function __sbD1DbRaw(path, what) {
|
|
|
3197
3251
|
if (__po) free(__po);
|
|
3198
3252
|
const char* __w; size_t __wl; char* __wo = 0;
|
|
3199
3253
|
porf_native_fetch_read_value(what, &__w, &__wl, &__wo);
|
|
3200
|
-
int __r = __wl && __w[0] == 'p' ? sb_db_page_size(__path) : sb_db_quota_freeze(__path, __wl && __w[0] == 'f');
|
|
3254
|
+
int __r = __wl && __w[0] == 'p' ? sb_db_page_size(__path) : __wl && __w[0] == 'c' ? sb_db_quota_freeze(__path, -1) : sb_db_quota_freeze(__path, __wl && __w[0] == 'f');
|
|
3201
3255
|
if (__wo) free(__wo);
|
|
3202
3256
|
free(__path);
|
|
3203
3257
|
char __n[16]; int __k = snprintf(__n, sizeof(__n), "%d", __r);
|
|
@@ -3455,11 +3509,31 @@ var __sbQuotaOps = {
|
|
|
3455
3509
|
// A database under LTX capture is frozen instead: every write is refused, because even a
|
|
3456
3510
|
// DELETE adds a file to the replication spool, which counts toward the quota.
|
|
3457
3511
|
var __sbD1Capped = {};
|
|
3512
|
+
var __sbD1Captured = {};
|
|
3513
|
+
var __sbD1Checkpointed = {};
|
|
3458
3514
|
function __sbD1Quota(path) {
|
|
3459
|
-
|
|
3515
|
+
// Whether capture owns this database, from the private helper (no SQL), before anything
|
|
3516
|
+
// below depends on it. A database that cannot open (or whose capture failed) is left to
|
|
3517
|
+
// the call itself, which reports why.
|
|
3518
|
+
const status = Number(__sbD1DbRaw(path, "captured"));
|
|
3519
|
+
if (status < 0) return;
|
|
3520
|
+
__sbD1Captured[path] = status === 1;
|
|
3521
|
+
let full = __sbQuotaFull();
|
|
3522
|
+
// Over the quota, a database's -wal file can hold space the app has already freed (a
|
|
3523
|
+
// DELETE, a VACUUM): checkpoint it into the database and truncate it, at most once a
|
|
3524
|
+
// second, then measure again. Not under capture: LTX owns that database's checkpoints.
|
|
3525
|
+
if (full && !__sbD1Captured[path] && Date.now() - (__sbD1Checkpointed[path] || 0) >= 1000) {
|
|
3526
|
+
__sbD1Checkpointed[path] = Date.now();
|
|
3527
|
+
JSON.parse(__sbSqlRaw(path, "PRAGMA wal_checkpoint(TRUNCATE)", "[]"));
|
|
3528
|
+
__sbQuotaAt = -1;
|
|
3529
|
+
full = __sbQuotaFull();
|
|
3530
|
+
}
|
|
3460
3531
|
if (__sbD1Capped[path] === full) return;
|
|
3461
3532
|
__sbD1Capped[path] = full;
|
|
3462
|
-
const
|
|
3533
|
+
const frozen = Number(__sbD1DbRaw(path, full ? "freeze" : "thaw"));
|
|
3534
|
+
if (frozen < 0) return;
|
|
3535
|
+
const captured = frozen === 1;
|
|
3536
|
+
__sbD1Captured[path] = captured;
|
|
3463
3537
|
if (full && captured) return;
|
|
3464
3538
|
const size = Number(__sbD1DbRaw(path, "page_size")) || 4096;
|
|
3465
3539
|
const pages = full
|
|
@@ -3499,6 +3573,10 @@ function __sbEnsureSchema() {
|
|
|
3499
3573
|
let hasKvBinary = false;
|
|
3500
3574
|
for (let i = 0; i < kvColumns.length; i++) if (kvColumns[i][1] === "binary") hasKvBinary = true;
|
|
3501
3575
|
if (!hasKvBinary) __sbSql(s, "ALTER TABLE kv ADD COLUMN binary INTEGER NOT NULL DEFAULT 0", []);
|
|
3576
|
+
// A key's metadata (put(key, value, { metadata })), as JSON text; same column as the broker's.
|
|
3577
|
+
let hasKvMetadata = false;
|
|
3578
|
+
for (let i = 0; i < kvColumns.length; i++) if (kvColumns[i][1] === "metadata") hasKvMetadata = true;
|
|
3579
|
+
if (!hasKvMetadata) __sbSql(s, "ALTER TABLE kv ADD COLUMN metadata TEXT", []);
|
|
3502
3580
|
// #56 — no `body` column: an object's bytes live in their own file under
|
|
3503
3581
|
// "<data-dir>/r2-blobs/" (sb_r2_put_c/sb_r2_get_c), never in the sqlite row.
|
|
3504
3582
|
__sbSql(
|
|
@@ -3512,6 +3590,10 @@ function __sbEnsureSchema() {
|
|
|
3512
3590
|
let hasBlobId = false;
|
|
3513
3591
|
for (let i = 0; i < r2Columns.length; i++) if (r2Columns[i][1] === "blob_id") hasBlobId = true;
|
|
3514
3592
|
if (!hasBlobId) __sbSql(s, "ALTER TABLE r2 ADD COLUMN blob_id TEXT", []);
|
|
3593
|
+
// The MD5 of an object's bytes, its etag from compatibility date 2026-10-26 (r2.md5).
|
|
3594
|
+
let hasR2Md5 = false;
|
|
3595
|
+
for (let i = 0; i < r2Columns.length; i++) if (r2Columns[i][1] === "md5") hasR2Md5 = true;
|
|
3596
|
+
if (!hasR2Md5) __sbSql(s, "ALTER TABLE r2 ADD COLUMN md5 TEXT", []);
|
|
3515
3597
|
__sbSql(
|
|
3516
3598
|
s,
|
|
3517
3599
|
"CREATE TABLE IF NOT EXISTS r2_upload (bucket TEXT NOT NULL, key TEXT NOT NULL, upload_id TEXT NOT NULL, " +
|
|
@@ -3583,6 +3665,51 @@ function __sbEnsureSchema() {
|
|
|
3583
3665
|
);
|
|
3584
3666
|
}
|
|
3585
3667
|
|
|
3668
|
+
// One page of r2.list for the shim from 2026-10-26: at most LIMIT entries after CURSOR,
|
|
3669
|
+
// keys under PREFIX, those holding DELIMITER after it grouped into delimitedPrefixes (a
|
|
3670
|
+
// group counts as one entry). Same rules as the broker's r2ListPage.
|
|
3671
|
+
// ponytail: with a delimiter every key after the cursor is read; an index on the
|
|
3672
|
+
// delimited prefix if buckets grow past ~100k keys.
|
|
3673
|
+
function __sbR2ListPage(store, msg) {
|
|
3674
|
+
const prefix = String(msg.prefix || "");
|
|
3675
|
+
const delimiter = String(msg.delimiter || "");
|
|
3676
|
+
const limit = Math.min(Math.max(Math.floor(Number(msg.limit)) || 1000, 1), 1000);
|
|
3677
|
+
const r = __sbSql(
|
|
3678
|
+
store,
|
|
3679
|
+
"SELECT key, size, etag, uploaded, http_json, custom_json, md5 FROM r2 WHERE bucket = ?1 AND substr(key, 1, length(?2)) = ?2 " +
|
|
3680
|
+
"AND key > ?3 ORDER BY key" + (delimiter ? "" : " LIMIT " + (limit + 1)),
|
|
3681
|
+
[msg.bucket, prefix, String(msg.cursor || "")],
|
|
3682
|
+
);
|
|
3683
|
+
const objects = [];
|
|
3684
|
+
const delimitedPrefixes = [];
|
|
3685
|
+
let last = "";
|
|
3686
|
+
let truncated = false;
|
|
3687
|
+
for (let i = 0; i < r.rows.length; i++) {
|
|
3688
|
+
const row = r.rows[i];
|
|
3689
|
+
const at = delimiter ? row[0].indexOf(delimiter, prefix.length) : -1;
|
|
3690
|
+
const group = at < 0 ? "" : row[0].slice(0, at + delimiter.length);
|
|
3691
|
+
if (!group || delimitedPrefixes[delimitedPrefixes.length - 1] !== group) {
|
|
3692
|
+
if (objects.length + delimitedPrefixes.length >= limit) {
|
|
3693
|
+
truncated = true;
|
|
3694
|
+
break;
|
|
3695
|
+
}
|
|
3696
|
+
if (group) delimitedPrefixes.push(group);
|
|
3697
|
+
else
|
|
3698
|
+
objects.push({
|
|
3699
|
+
key: row[0],
|
|
3700
|
+
size: Number(row[1]),
|
|
3701
|
+
etag: row[2],
|
|
3702
|
+
uploaded: row[3],
|
|
3703
|
+
httpMetadata: JSON.parse(row[4] || "{}"),
|
|
3704
|
+
customMetadata: JSON.parse(row[5] || "{}"),
|
|
3705
|
+
md5: row[6],
|
|
3706
|
+
});
|
|
3707
|
+
}
|
|
3708
|
+
last = row[0];
|
|
3709
|
+
}
|
|
3710
|
+
return { ok: true, objects, delimitedPrefixes, truncated, cursor: truncated ? last : "" };
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3586
3713
|
function __sbHex(n) {
|
|
3587
3714
|
let out = "";
|
|
3588
3715
|
const bytes = __sbRandomBytes(String(n));
|
|
@@ -3602,7 +3729,7 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
3602
3729
|
if (op === "ping") return { ok: true, op: "pong", echo: msg.msg };
|
|
3603
3730
|
|
|
3604
3731
|
if (op === "kv.get") {
|
|
3605
|
-
const r = __sbSql(store, "SELECT value, expires_at, binary FROM kv WHERE ns = ? AND key = ?", [
|
|
3732
|
+
const r = __sbSql(store, "SELECT value, expires_at, binary, metadata FROM kv WHERE ns = ? AND key = ?", [
|
|
3606
3733
|
msg.ns,
|
|
3607
3734
|
msg.key,
|
|
3608
3735
|
]);
|
|
@@ -3612,7 +3739,14 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
3612
3739
|
__sbSql(store, "DELETE FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
|
|
3613
3740
|
return { ok: true, found: false, value: null };
|
|
3614
3741
|
}
|
|
3615
|
-
return {
|
|
3742
|
+
return {
|
|
3743
|
+
ok: true,
|
|
3744
|
+
found: true,
|
|
3745
|
+
value: r.rows[0][0],
|
|
3746
|
+
binary: r.rows[0][2] === 1,
|
|
3747
|
+
metadata: r.rows[0][3],
|
|
3748
|
+
expiration: expiresAt == null ? null : Math.floor(expiresAt / 1000),
|
|
3749
|
+
};
|
|
3616
3750
|
}
|
|
3617
3751
|
if (op === "kv.put") {
|
|
3618
3752
|
// #58 — CF rejects expirationTtl under 60 seconds; matched here.
|
|
@@ -3626,8 +3760,8 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
3626
3760
|
}
|
|
3627
3761
|
__sbSql(
|
|
3628
3762
|
store,
|
|
3629
|
-
"INSERT INTO kv (ns, key, value, expires_at, binary) VALUES (?1,?2,?3,?4,?5) ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4, binary = ?5",
|
|
3630
|
-
[msg.ns, msg.key, msg.value, expiresAt, msg.binary === true ? 1 : 0],
|
|
3763
|
+
"INSERT INTO kv (ns, key, value, expires_at, binary, metadata) VALUES (?1,?2,?3,?4,?5,?6) ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4, binary = ?5, metadata = ?6",
|
|
3764
|
+
[msg.ns, msg.key, msg.value, expiresAt, msg.binary === true ? 1 : 0, msg.metadata == null ? null : String(msg.metadata)],
|
|
3631
3765
|
);
|
|
3632
3766
|
return { ok: true };
|
|
3633
3767
|
}
|
|
@@ -3636,14 +3770,28 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
3636
3770
|
return { ok: true };
|
|
3637
3771
|
}
|
|
3638
3772
|
if (op === "kv.list") {
|
|
3773
|
+
// The prefix matches exactly: LIKE ignored case and read "_" and "%" in it as wildcards.
|
|
3774
|
+
// With a limit (the shim from 2026-10-25), a page of entries after the cursor (the last
|
|
3775
|
+
// key of the previous page), with each key's expiration and metadata; without one,
|
|
3776
|
+
// every key, as before.
|
|
3777
|
+
const prefix = msg.prefix || "";
|
|
3778
|
+
const paged = msg.limit != null;
|
|
3779
|
+
const limit = paged ? Math.max(0, Math.floor(Number(msg.limit))) : -1;
|
|
3639
3780
|
const r = __sbSql(
|
|
3640
3781
|
store,
|
|
3641
|
-
"SELECT key FROM kv WHERE ns = ? AND key
|
|
3642
|
-
|
|
3782
|
+
"SELECT key, expires_at, metadata FROM kv WHERE ns = ?1 AND substr(key, 1, length(?2)) = ?2 AND key > ?3 " +
|
|
3783
|
+
"AND (expires_at IS NULL OR expires_at > ?4) ORDER BY key LIMIT ?5",
|
|
3784
|
+
[msg.ns, prefix, paged && msg.cursor ? String(msg.cursor) : "", Date.now(), paged ? limit + 1 : -1],
|
|
3643
3785
|
);
|
|
3644
3786
|
const keys = [];
|
|
3645
|
-
|
|
3646
|
-
|
|
3787
|
+
const entries = [];
|
|
3788
|
+
const count = paged ? Math.min(r.rows.length, limit) : r.rows.length;
|
|
3789
|
+
for (let i = 0; i < count; i++) {
|
|
3790
|
+
keys.push(r.rows[i][0]);
|
|
3791
|
+
entries.push({ name: r.rows[i][0], expiration: r.rows[i][1] == null ? null : Math.floor(r.rows[i][1] / 1000), metadata: r.rows[i][2] });
|
|
3792
|
+
}
|
|
3793
|
+
const complete = !paged || r.rows.length <= limit;
|
|
3794
|
+
return { ok: true, keys, entries, list_complete: complete, cursor: complete ? "" : keys[keys.length - 1] };
|
|
3647
3795
|
}
|
|
3648
3796
|
|
|
3649
3797
|
if (op === "assets.get") {
|
|
@@ -3973,7 +4121,7 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
3973
4121
|
const wantsBody = op === "r2.get";
|
|
3974
4122
|
const r = __sbSql(
|
|
3975
4123
|
store,
|
|
3976
|
-
"SELECT size, etag, uploaded, http_json, custom_json, blob_id FROM r2 WHERE bucket = ? AND key = ?",
|
|
4124
|
+
"SELECT size, etag, uploaded, http_json, custom_json, blob_id, md5 FROM r2 WHERE bucket = ? AND key = ?",
|
|
3977
4125
|
[msg.bucket, msg.key],
|
|
3978
4126
|
);
|
|
3979
4127
|
if (!r.rows.length) return { ok: true, found: false };
|
|
@@ -3989,6 +4137,7 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
3989
4137
|
uploaded: row[2],
|
|
3990
4138
|
httpMetadata: JSON.parse(row[3] || "{}"),
|
|
3991
4139
|
customMetadata: JSON.parse(row[4] || "{}"),
|
|
4140
|
+
md5: row[6],
|
|
3992
4141
|
},
|
|
3993
4142
|
blobId: row[5] || "",
|
|
3994
4143
|
body: wantsBody
|
|
@@ -4011,10 +4160,23 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
4011
4160
|
);
|
|
4012
4161
|
return { ok: true };
|
|
4013
4162
|
}
|
|
4163
|
+
if (op === "r2.md5") {
|
|
4164
|
+
// Recorded after the put that wrote these bytes (etag): the shim from 2026-10-26 hashes them.
|
|
4165
|
+
__sbSql(store, "UPDATE r2 SET md5 = ?1 WHERE bucket = ?2 AND key = ?3 AND etag = ?4", [
|
|
4166
|
+
String(msg.md5),
|
|
4167
|
+
msg.bucket,
|
|
4168
|
+
msg.key,
|
|
4169
|
+
String(msg.etag),
|
|
4170
|
+
]);
|
|
4171
|
+
return { ok: true };
|
|
4172
|
+
}
|
|
4014
4173
|
if (op === "r2.list") {
|
|
4174
|
+
// The prefix matches exactly at every date: LIKE ignored case and read "_" and "%" as
|
|
4175
|
+
// wildcards. With `page` (the shim from 2026-10-26), a page after the cursor.
|
|
4176
|
+
if (msg.page) return __sbR2ListPage(store, msg);
|
|
4015
4177
|
const r = __sbSql(
|
|
4016
4178
|
store,
|
|
4017
|
-
"SELECT key, size, etag, uploaded FROM r2 WHERE bucket = ? AND key
|
|
4179
|
+
"SELECT key, size, etag, uploaded FROM r2 WHERE bucket = ?1 AND substr(key, 1, length(?2)) = ?2 ORDER BY key",
|
|
4018
4180
|
[msg.bucket, msg.prefix || ""],
|
|
4019
4181
|
);
|
|
4020
4182
|
const objects = [];
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// LTX chain restore, the same independent checker as native-ltx.ts (copied, so that test
|
|
2
|
+
// stays as it is): every file's CRCs, checksums and TXIDs verified, pages applied in order.
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { readFile, readdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// Independent CRC baseline in Bun. This intentionally uses BigInt, unlike C.
|
|
8
|
+
const table: bigint[] = [];
|
|
9
|
+
for (let i = 0; i < 256; i++) {
|
|
10
|
+
let c = BigInt(i);
|
|
11
|
+
for (let j = 0; j < 8; j++) c = (c >> 1n) ^ (c & 1n ? 0xd800000000000000n : 0n);
|
|
12
|
+
table.push(c);
|
|
13
|
+
}
|
|
14
|
+
function crc(data: Uint8Array, init = 0xffffffffffffffffn) {
|
|
15
|
+
let c = init;
|
|
16
|
+
for (const b of data) c = table[Number((c ^ BigInt(b)) & 255n)] ^ (c >> 8n);
|
|
17
|
+
return c;
|
|
18
|
+
}
|
|
19
|
+
const flag = 0x8000000000000000n,
|
|
20
|
+
mask = 0xffffffffffffffffn;
|
|
21
|
+
function pageCrc(pgno: number, page: Uint8Array) {
|
|
22
|
+
const p = new Uint8Array(4);
|
|
23
|
+
new DataView(p.buffer).setUint32(0, pgno);
|
|
24
|
+
return flag | (crc(page, crc(p)) ^ mask);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function restore(epoch: string, output: string) {
|
|
28
|
+
const files = (await readdir(epoch)).filter((n) => n.endsWith(".ltx")).sort();
|
|
29
|
+
let db = new Uint8Array(0),
|
|
30
|
+
checksum = 0n,
|
|
31
|
+
txid = 0n;
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
const b = new Uint8Array(await readFile(join(epoch, file)));
|
|
34
|
+
const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
35
|
+
assert.equal(new TextDecoder().decode(b.subarray(0, 4)), "LTX1");
|
|
36
|
+
const pageSize = v.getUint32(8),
|
|
37
|
+
commit = v.getUint32(12);
|
|
38
|
+
assert.equal(v.getBigUint64(16), txid + 1n);
|
|
39
|
+
assert.equal(v.getBigUint64(40), checksum);
|
|
40
|
+
const next = new Uint8Array(commit * pageSize);
|
|
41
|
+
next.set(db.subarray(0, next.length));
|
|
42
|
+
db = next;
|
|
43
|
+
let offset = 100,
|
|
44
|
+
hash = crc(b.subarray(0, 100));
|
|
45
|
+
const entries = [];
|
|
46
|
+
for (;;) {
|
|
47
|
+
const begin = offset,
|
|
48
|
+
pgno = v.getUint32(offset),
|
|
49
|
+
flags = v.getUint16(offset + 4);
|
|
50
|
+
hash = crc(b.subarray(offset, offset + 6), hash);
|
|
51
|
+
offset += 6;
|
|
52
|
+
if (!pgno) {
|
|
53
|
+
assert.equal(flags, 0);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
assert.equal(flags, 1);
|
|
57
|
+
assert(pgno <= commit);
|
|
58
|
+
const size = v.getUint32(offset);
|
|
59
|
+
hash = crc(b.subarray(offset, offset + 4), hash);
|
|
60
|
+
offset += 4;
|
|
61
|
+
const stop = offset + size;
|
|
62
|
+
assert(stop <= b.length);
|
|
63
|
+
assert.equal(b[offset++], 0xf0);
|
|
64
|
+
let literals = 15,
|
|
65
|
+
byte;
|
|
66
|
+
do {
|
|
67
|
+
byte = b[offset++];
|
|
68
|
+
literals += byte;
|
|
69
|
+
} while (byte === 255);
|
|
70
|
+
assert.equal(literals, pageSize);
|
|
71
|
+
assert.equal(offset + pageSize, stop);
|
|
72
|
+
const page = b.subarray(offset, stop);
|
|
73
|
+
db.set(page, (pgno - 1) * pageSize);
|
|
74
|
+
hash = crc(page, hash);
|
|
75
|
+
offset = stop;
|
|
76
|
+
entries.push({ pgno, offset: begin, size: stop - begin });
|
|
77
|
+
}
|
|
78
|
+
const indexStart = offset;
|
|
79
|
+
function varint() {
|
|
80
|
+
let n = 0n,
|
|
81
|
+
s = 0n;
|
|
82
|
+
for (let i = 0; i < 10; i++) {
|
|
83
|
+
assert(offset < b.length);
|
|
84
|
+
const c = b[offset++];
|
|
85
|
+
n |= BigInt(c & 127) << s;
|
|
86
|
+
if (c < 128) return Number(n);
|
|
87
|
+
s += 7n;
|
|
88
|
+
}
|
|
89
|
+
throw new Error("varint overflow");
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
assert.equal(varint(), entry.pgno);
|
|
93
|
+
assert.equal(varint(), entry.offset);
|
|
94
|
+
assert.equal(varint(), entry.size);
|
|
95
|
+
}
|
|
96
|
+
assert.equal(varint(), 0);
|
|
97
|
+
assert.equal(v.getBigUint64(offset), BigInt(offset - indexStart));
|
|
98
|
+
offset += 8;
|
|
99
|
+
hash = crc(b.subarray(indexStart, offset + 8), hash);
|
|
100
|
+
checksum = flag;
|
|
101
|
+
for (let p = 1; p <= commit; p++)
|
|
102
|
+
checksum = flag | (checksum ^ pageCrc(p, db.subarray((p - 1) * pageSize, p * pageSize)));
|
|
103
|
+
assert.equal(v.getBigUint64(offset), checksum, file);
|
|
104
|
+
assert.equal(v.getBigUint64(offset + 8), flag | (hash ^ mask), file);
|
|
105
|
+
assert.equal(offset + 16, b.length);
|
|
106
|
+
txid = v.getBigUint64(24);
|
|
107
|
+
}
|
|
108
|
+
await writeFile(output, db);
|
|
109
|
+
return { files: files.length, txid };
|
|
110
|
+
}
|