@vaur94/agz-memory 0.5.0 → 0.5.1
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/ARCHITECTURE.md +3 -3
- package/CHANGELOG.md +17 -0
- package/README.md +15 -12
- package/README.tr.md +15 -12
- package/dist/admin.js +687 -124
- package/dist/core.js +852 -91
- package/dist/server.js +945 -163
- package/dist/types/admin/quarantine.d.ts +15 -0
- package/dist/types/admin/reindex.d.ts +41 -0
- package/dist/types/config.d.ts +1 -0
- package/dist/types/contracts/error.d.ts +16 -0
- package/dist/types/contracts/limits.d.ts +18 -0
- package/dist/types/contracts/mutation.d.ts +31 -0
- package/dist/types/contracts/pagination.d.ts +27 -0
- package/dist/types/core.d.ts +4 -0
- package/dist/types/db/health.d.ts +3 -1
- package/dist/types/db/legacy-health.d.ts +3 -1
- package/dist/types/db.d.ts +11 -1
- package/dist/types/security/quarantine-key.d.ts +47 -0
- package/dist/types/server.d.ts +1 -1
- package/dist/types/store/capture.d.ts +5 -1
- package/dist/types/store/outbox.d.ts +10 -1
- package/dist/types/store.d.ts +27 -9
- package/dist/types/version.d.ts +1 -1
- package/docs/backup-restore-runbook.md +16 -16
- package/docs/backup-restore-runbook.tr.md +16 -16
- package/docs/schema-v11.md +1 -1
- package/package.json +3 -3
- package/skills/index.json +1 -1
package/dist/admin.js
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/admin/index.ts
|
|
5
|
-
import { createHash as
|
|
5
|
+
import { createHash as createHash8 } from "crypto";
|
|
6
6
|
import { Database as Database4 } from "bun:sqlite";
|
|
7
7
|
import {
|
|
8
8
|
existsSync as existsSync5,
|
|
9
|
-
lstatSync as
|
|
9
|
+
lstatSync as lstatSync5,
|
|
10
10
|
readdirSync as readdirSync2,
|
|
11
|
-
readFileSync as
|
|
12
|
-
|
|
11
|
+
readFileSync as readFileSync5,
|
|
12
|
+
realpathSync,
|
|
13
|
+
rmSync as rmSync4
|
|
13
14
|
} from "fs";
|
|
14
15
|
import { basename as basename3, dirname as dirname3, resolve as resolve3 } from "path";
|
|
15
16
|
|
|
@@ -18,7 +19,8 @@ import { homedir } from "os";
|
|
|
18
19
|
import { join } from "path";
|
|
19
20
|
function resolveConfig(environment = process.env) {
|
|
20
21
|
const databasePath = environment.OPENCODE_MEMORY_DATABASE_PATH?.trim() || join(environment.HOME ?? homedir(), ".local", "share", "opencode-memory", "memory.sqlite");
|
|
21
|
-
|
|
22
|
+
const quarantineKeyringPath = environment.OPENCODE_MEMORY_QUARANTINE_KEYRING_PATH?.trim() || `${databasePath}.quarantine-keys`;
|
|
23
|
+
return { databasePath, quarantineKeyringPath };
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
// src/db.ts
|
|
@@ -746,7 +748,7 @@ function inspectDatabase(db) {
|
|
|
746
748
|
}
|
|
747
749
|
return { integrity, foreignKeyViolations, schemaVersion, counts };
|
|
748
750
|
}
|
|
749
|
-
function assertHealthyDatabase(db) {
|
|
751
|
+
function assertHealthyDatabase(db, options = {}) {
|
|
750
752
|
const health = inspectDatabase(db);
|
|
751
753
|
if (health.integrity !== "ok") {
|
|
752
754
|
throw new Error(`database integrity check failed: ${health.integrity}`);
|
|
@@ -754,7 +756,7 @@ function assertHealthyDatabase(db) {
|
|
|
754
756
|
if (health.foreignKeyViolations.length > 0) {
|
|
755
757
|
throw new Error(`database foreign key check failed: ${health.foreignKeyViolations.length} violation(s)`);
|
|
756
758
|
}
|
|
757
|
-
if (health.schemaVersion === 11)
|
|
759
|
+
if (options.verifySchema !== false && health.schemaVersion === 11)
|
|
758
760
|
assertSchemaV11(db);
|
|
759
761
|
return health;
|
|
760
762
|
}
|
|
@@ -2180,7 +2182,7 @@ var V9_V10_COLUMNS = {
|
|
|
2180
2182
|
"completed_at"
|
|
2181
2183
|
]
|
|
2182
2184
|
};
|
|
2183
|
-
function assertLegacySchemaIdentity(db, version) {
|
|
2185
|
+
function assertLegacySchemaIdentity(db, version, options = {}) {
|
|
2184
2186
|
if (version < 2 || version > 10)
|
|
2185
2187
|
throw new Error("unrecognized_database");
|
|
2186
2188
|
const applicationID = db.query("PRAGMA application_id").get().application_id;
|
|
@@ -2188,12 +2190,14 @@ function assertLegacySchemaIdentity(db, version) {
|
|
|
2188
2190
|
throw new Error("unrecognized_database");
|
|
2189
2191
|
if (version === 2 && tableExists(db, "memory_items")) {
|
|
2190
2192
|
assertV2Identity(db);
|
|
2191
|
-
|
|
2193
|
+
if (options.verifyHealth)
|
|
2194
|
+
assertHealthyDatabase(db);
|
|
2192
2195
|
return;
|
|
2193
2196
|
}
|
|
2194
2197
|
if (version < 8) {
|
|
2195
2198
|
assertPreV8Identity(db, version);
|
|
2196
|
-
|
|
2199
|
+
if (options.verifyHealth)
|
|
2200
|
+
assertHealthyDatabase(db);
|
|
2197
2201
|
return;
|
|
2198
2202
|
}
|
|
2199
2203
|
const states = db.query("SELECT version FROM schema_state").all();
|
|
@@ -2218,7 +2222,8 @@ function assertLegacySchemaIdentity(db, version) {
|
|
|
2218
2222
|
throw new Error("unrecognized_database");
|
|
2219
2223
|
}
|
|
2220
2224
|
}
|
|
2221
|
-
|
|
2225
|
+
if (options.verifyHealth)
|
|
2226
|
+
assertHealthyDatabase(db);
|
|
2222
2227
|
if (version === 10)
|
|
2223
2228
|
assertV10SourceDatabase(db);
|
|
2224
2229
|
}
|
|
@@ -3416,7 +3421,7 @@ function migrateV9ToV10(db) {
|
|
|
3416
3421
|
}
|
|
3417
3422
|
|
|
3418
3423
|
// src/version.ts
|
|
3419
|
-
var PRODUCT_VERSION = "0.5.
|
|
3424
|
+
var PRODUCT_VERSION = "0.5.1";
|
|
3420
3425
|
|
|
3421
3426
|
// src/db.ts
|
|
3422
3427
|
var DDL = `
|
|
@@ -3459,15 +3464,25 @@ CREATE INDEX IF NOT EXISTS note_edges_target_idx ON note_edges(project_id, targe
|
|
|
3459
3464
|
CREATE TABLE IF NOT EXISTS schema_state (version INTEGER PRIMARY KEY);
|
|
3460
3465
|
`;
|
|
3461
3466
|
var PRE_OPEN_PROBE_TIMEOUT_MS = 5000;
|
|
3462
|
-
function
|
|
3467
|
+
function timeMigrationStage(timing, stage, work) {
|
|
3468
|
+
if (!timing)
|
|
3469
|
+
return work();
|
|
3470
|
+
const started = performance.now();
|
|
3471
|
+
try {
|
|
3472
|
+
return work();
|
|
3473
|
+
} finally {
|
|
3474
|
+
timing.phases.push({ stage, elapsedMs: Math.round((performance.now() - started) * 1000) / 1000 });
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
function openMemoryDatabase(path, options = {}) {
|
|
3463
3478
|
ensureDatabaseParent(path);
|
|
3464
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3479
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3465
3480
|
recoverStaleMaintenanceGate(path, () => assertSupportedDatabaseBeforeOpen(path));
|
|
3466
3481
|
let lock = acquireMigrationLock(path, SCHEMA_VERSION);
|
|
3467
3482
|
let lease = acquireDatabaseLease(path);
|
|
3468
3483
|
let db;
|
|
3469
3484
|
try {
|
|
3470
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3485
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3471
3486
|
db = openDatabase(path);
|
|
3472
3487
|
} catch (error) {
|
|
3473
3488
|
lease.release();
|
|
@@ -3489,7 +3504,7 @@ function openMemoryDatabase(path) {
|
|
|
3489
3504
|
lease.release();
|
|
3490
3505
|
lease = undefined;
|
|
3491
3506
|
lease = acquireDatabaseLease(path);
|
|
3492
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3507
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3493
3508
|
db = openDatabase(path);
|
|
3494
3509
|
dbOpen = true;
|
|
3495
3510
|
if (hasApplicationObjects(db)) {
|
|
@@ -3539,7 +3554,7 @@ function openMemoryDatabase(path) {
|
|
|
3539
3554
|
lease.release();
|
|
3540
3555
|
lease = undefined;
|
|
3541
3556
|
lease = acquireDatabaseLease(path);
|
|
3542
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3557
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3543
3558
|
db = openDatabase(path);
|
|
3544
3559
|
dbOpen = true;
|
|
3545
3560
|
let migrationVersion = getSchemaVersion(db);
|
|
@@ -3567,7 +3582,7 @@ function openMemoryDatabase(path) {
|
|
|
3567
3582
|
lease.release();
|
|
3568
3583
|
lease = undefined;
|
|
3569
3584
|
maintenance = acquireMaintenanceGate(path);
|
|
3570
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3585
|
+
timeMigrationStage(options.timing, "source-validation", () => assertSupportedDatabaseBeforeOpen(path, false));
|
|
3571
3586
|
db = openDatabase(path);
|
|
3572
3587
|
dbOpen = true;
|
|
3573
3588
|
migrationVersion = getSchemaVersion(db);
|
|
@@ -3597,12 +3612,12 @@ function openMemoryDatabase(path) {
|
|
|
3597
3612
|
lease = undefined;
|
|
3598
3613
|
return opened3;
|
|
3599
3614
|
}
|
|
3600
|
-
backup = createVerifiedBackup(db, path, migrationVersion?.version ?? 2, SCHEMA_VERSION, PRODUCT_VERSION);
|
|
3615
|
+
backup = timeMigrationStage(options.timing, "backup-checkpoint", () => createVerifiedBackup(db, path, migrationVersion?.version ?? 2, SCHEMA_VERSION, PRODUCT_VERSION));
|
|
3601
3616
|
db.exec("PRAGMA foreign_keys=OFF");
|
|
3602
3617
|
if (!migrationVersion && hasLegacyV2(db)) {
|
|
3603
3618
|
db.exec(DDL);
|
|
3604
3619
|
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
3605
|
-
migrateFromV2(db, path);
|
|
3620
|
+
timeMigrationStage(options.timing, "v2-import", () => migrateFromV2(db, path));
|
|
3606
3621
|
} else if (!migrationVersion) {
|
|
3607
3622
|
db.exec(DDL);
|
|
3608
3623
|
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
@@ -3618,24 +3633,25 @@ function openMemoryDatabase(path) {
|
|
|
3618
3633
|
}
|
|
3619
3634
|
let version = getSchemaVersion(db)?.version ?? 8;
|
|
3620
3635
|
if (version < 9) {
|
|
3621
|
-
db.transaction(() => migrateV8ToV9(db))();
|
|
3636
|
+
timeMigrationStage(options.timing, "v8-to-v9", () => db.transaction(() => migrateV8ToV9(db))());
|
|
3622
3637
|
version = 9;
|
|
3623
3638
|
}
|
|
3624
3639
|
if (version < 10) {
|
|
3625
|
-
db.transaction(() => migrateV9ToV10(db))();
|
|
3640
|
+
timeMigrationStage(options.timing, "v9-to-v10", () => db.transaction(() => migrateV9ToV10(db))());
|
|
3626
3641
|
version = 10;
|
|
3627
3642
|
}
|
|
3628
3643
|
if (version < SCHEMA_VERSION) {
|
|
3629
|
-
db.transaction(() => {
|
|
3644
|
+
timeMigrationStage(options.timing, "v10-to-v11", () => db.transaction(() => {
|
|
3630
3645
|
db.exec(`PRAGMA application_id = ${APPLICATION_ID}`);
|
|
3631
3646
|
migrateV10ToV11(db);
|
|
3632
|
-
})();
|
|
3647
|
+
})());
|
|
3633
3648
|
}
|
|
3634
3649
|
db.exec("PRAGMA foreign_keys=ON");
|
|
3635
3650
|
if (db.query("PRAGMA foreign_keys").get().foreign_keys !== 1) {
|
|
3636
3651
|
throw new Error("failed to enable database foreign keys");
|
|
3637
3652
|
}
|
|
3638
|
-
|
|
3653
|
+
timeMigrationStage(options.timing, "fingerprint", () => assertSchemaV11(db));
|
|
3654
|
+
timeMigrationStage(options.timing, "deep-health", () => assertHealthyDatabase(db, { verifySchema: false }));
|
|
3639
3655
|
console.warn(`[agz-memory] migrated to v${SCHEMA_VERSION} (backup: ${backup.manifestPath})`);
|
|
3640
3656
|
backup = undefined;
|
|
3641
3657
|
db.close();
|
|
@@ -3743,11 +3759,11 @@ function openDatabase(path) {
|
|
|
3743
3759
|
throw error;
|
|
3744
3760
|
}
|
|
3745
3761
|
}
|
|
3746
|
-
function assertSupportedDatabaseBeforeOpen(path) {
|
|
3762
|
+
function assertSupportedDatabaseBeforeOpen(path, verifyHealth = true) {
|
|
3747
3763
|
const deadline = Date.now() + PRE_OPEN_PROBE_TIMEOUT_MS;
|
|
3748
3764
|
while (true) {
|
|
3749
3765
|
try {
|
|
3750
|
-
assertSupportedDatabaseBeforeOpenOnce(path);
|
|
3766
|
+
assertSupportedDatabaseBeforeOpenOnce(path, verifyHealth);
|
|
3751
3767
|
return;
|
|
3752
3768
|
} catch (error) {
|
|
3753
3769
|
if (!isSQLiteBusyError(error) || Date.now() >= deadline)
|
|
@@ -3756,19 +3772,19 @@ function assertSupportedDatabaseBeforeOpen(path) {
|
|
|
3756
3772
|
}
|
|
3757
3773
|
}
|
|
3758
3774
|
}
|
|
3759
|
-
function assertSupportedDatabaseBeforeOpenOnce(path) {
|
|
3775
|
+
function assertSupportedDatabaseBeforeOpenOnce(path, verifyHealth) {
|
|
3760
3776
|
assertDatabasePath(path);
|
|
3761
3777
|
if (!existsSync4(path))
|
|
3762
3778
|
return;
|
|
3763
3779
|
const db = new Database3(path, { readonly: true });
|
|
3764
3780
|
try {
|
|
3765
3781
|
assertDatabasePath(path);
|
|
3766
|
-
assertSupportedDatabase(db);
|
|
3782
|
+
assertSupportedDatabase(db, verifyHealth);
|
|
3767
3783
|
} finally {
|
|
3768
3784
|
db.close();
|
|
3769
3785
|
}
|
|
3770
3786
|
}
|
|
3771
|
-
function assertSupportedDatabase(db) {
|
|
3787
|
+
function assertSupportedDatabase(db, verifyHealth = true) {
|
|
3772
3788
|
const existingVersion = getSchemaVersion(db);
|
|
3773
3789
|
if (existingVersion && existingVersion.version > SCHEMA_VERSION) {
|
|
3774
3790
|
throw new Error(`database schema v${existingVersion.version} is newer than supported v${SCHEMA_VERSION}`);
|
|
@@ -3779,7 +3795,7 @@ function assertSupportedDatabase(db) {
|
|
|
3779
3795
|
if (!existingVersion) {
|
|
3780
3796
|
if (!hasLegacyV2(db))
|
|
3781
3797
|
throw new Error("unrecognized_database");
|
|
3782
|
-
assertLegacySchemaIdentity(db, 2);
|
|
3798
|
+
assertLegacySchemaIdentity(db, 2, { verifyHealth });
|
|
3783
3799
|
return;
|
|
3784
3800
|
}
|
|
3785
3801
|
if (existingVersion.version === SCHEMA_VERSION || hasV11Marker) {
|
|
@@ -3789,7 +3805,7 @@ function assertSupportedDatabase(db) {
|
|
|
3789
3805
|
if (existingVersion.version < 2 || existingVersion.version > 10) {
|
|
3790
3806
|
throw new Error("unrecognized_database");
|
|
3791
3807
|
}
|
|
3792
|
-
assertLegacySchemaIdentity(db, existingVersion.version);
|
|
3808
|
+
assertLegacySchemaIdentity(db, existingVersion.version, { verifyHealth });
|
|
3793
3809
|
}
|
|
3794
3810
|
function assertDatabasePath(path) {
|
|
3795
3811
|
try {
|
|
@@ -4274,17 +4290,550 @@ function derivedHashMismatches(db) {
|
|
|
4274
4290
|
return mismatches;
|
|
4275
4291
|
}
|
|
4276
4292
|
|
|
4293
|
+
// src/admin/quarantine.ts
|
|
4294
|
+
var KEYED_VERSION = /^redaction\/1;quarantine-key=([0-9a-f]{24});quarantine-digest=2$/;
|
|
4295
|
+
var UNAVAILABLE_VERSION = /^redaction\/1;quarantine-key=unavailable;quarantine-digest=2$/;
|
|
4296
|
+
function quarantinePrivacyReport(db) {
|
|
4297
|
+
const rows = db.query(`SELECT redaction_version, COUNT(*) AS count
|
|
4298
|
+
FROM capture_events
|
|
4299
|
+
WHERE state = 'quarantined'
|
|
4300
|
+
GROUP BY redaction_version`).all();
|
|
4301
|
+
let quarantinedEvents = 0;
|
|
4302
|
+
let keyedEvents = 0;
|
|
4303
|
+
let unavailableKeyEvents = 0;
|
|
4304
|
+
let legacyOrUnknownEvents = 0;
|
|
4305
|
+
const keyIDs = new Set;
|
|
4306
|
+
for (const row of rows) {
|
|
4307
|
+
quarantinedEvents += row.count;
|
|
4308
|
+
const keyed = KEYED_VERSION.exec(row.redaction_version);
|
|
4309
|
+
if (keyed) {
|
|
4310
|
+
keyedEvents += row.count;
|
|
4311
|
+
keyIDs.add(keyed[1]);
|
|
4312
|
+
} else if (UNAVAILABLE_VERSION.test(row.redaction_version)) {
|
|
4313
|
+
unavailableKeyEvents += row.count;
|
|
4314
|
+
} else {
|
|
4315
|
+
legacyOrUnknownEvents += row.count;
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
return {
|
|
4319
|
+
quarantinedEvents,
|
|
4320
|
+
keyedEvents,
|
|
4321
|
+
unavailableKeyEvents,
|
|
4322
|
+
legacyOrUnknownEvents,
|
|
4323
|
+
keyIDs: [...keyIDs].sort(),
|
|
4324
|
+
digest: {
|
|
4325
|
+
algorithm: "HMAC-SHA256",
|
|
4326
|
+
input: "quarantine-source-identity-and-redacted-payload/2",
|
|
4327
|
+
storage: "capture_events.payload_hash",
|
|
4328
|
+
keyID: "capture_events.redaction_version quarantine-key suffix"
|
|
4329
|
+
}
|
|
4330
|
+
};
|
|
4331
|
+
}
|
|
4332
|
+
|
|
4333
|
+
// src/admin/reindex.ts
|
|
4334
|
+
import { spawnSync } from "child_process";
|
|
4335
|
+
import { createHash as createHash7, randomUUID as randomUUID7 } from "crypto";
|
|
4336
|
+
import {
|
|
4337
|
+
closeSync as closeSync3,
|
|
4338
|
+
constants as constants3,
|
|
4339
|
+
fsyncSync as fsyncSync3,
|
|
4340
|
+
fstatSync as fstatSync2,
|
|
4341
|
+
lstatSync as lstatSync4,
|
|
4342
|
+
mkdirSync as mkdirSync4,
|
|
4343
|
+
openSync as openSync3,
|
|
4344
|
+
readFileSync as readFileSync4,
|
|
4345
|
+
renameSync as renameSync4,
|
|
4346
|
+
rmSync as rmSync3,
|
|
4347
|
+
writeSync as writeSync2
|
|
4348
|
+
} from "fs";
|
|
4349
|
+
import { hostname as hostname3 } from "os";
|
|
4350
|
+
import { join as join4 } from "path";
|
|
4351
|
+
var MAX_BATCH_SIZE = 500;
|
|
4352
|
+
function classifyReindexOwner(owner, localHostname, processAlive, currentProcessStart) {
|
|
4353
|
+
if (!isOwnerMetadata(owner) || owner.hostname !== localHostname)
|
|
4354
|
+
return "unverifiable";
|
|
4355
|
+
if (!processAlive)
|
|
4356
|
+
return "stale";
|
|
4357
|
+
if (!currentProcessStart)
|
|
4358
|
+
return "unverifiable";
|
|
4359
|
+
return currentProcessStart === owner.processStart ? "live" : "stale";
|
|
4360
|
+
}
|
|
4361
|
+
function runResumableReindex(path, databaseID, backend, batchSize, maxBatches, testOptions = {}) {
|
|
4362
|
+
if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > MAX_BATCH_SIZE) {
|
|
4363
|
+
throw new Error(`reindex --batch-size must be 1..${MAX_BATCH_SIZE}`);
|
|
4364
|
+
}
|
|
4365
|
+
if (maxBatches !== undefined && (!Number.isSafeInteger(maxBatches) || maxBatches < 1 || maxBatches > 1e4)) {
|
|
4366
|
+
throw new Error("reindex --max-batches must be 1..10000");
|
|
4367
|
+
}
|
|
4368
|
+
const databaseFile = captureDatabaseFile(path);
|
|
4369
|
+
const directory = openSidecarDirectory(path);
|
|
4370
|
+
const file = join4(directory.path, `${createHash7("sha256").update(backend).digest("hex")}.json`);
|
|
4371
|
+
const release = acquireOwnerLock(file, directory, testOptions);
|
|
4372
|
+
try {
|
|
4373
|
+
testOptions.afterOwnerLockAcquired?.();
|
|
4374
|
+
const existing = readState(file, directory, path, databaseID, backend);
|
|
4375
|
+
let state = existing ?? createState(path, databaseID, backend, databaseFile);
|
|
4376
|
+
if (!existing)
|
|
4377
|
+
writeState(file, directory, state);
|
|
4378
|
+
let batches = 0;
|
|
4379
|
+
while (true) {
|
|
4380
|
+
const result = runBatch(path, state, batchSize, testOptions);
|
|
4381
|
+
state = result.state;
|
|
4382
|
+
batches++;
|
|
4383
|
+
if (result.done) {
|
|
4384
|
+
removePrivateFile(file, directory, "reindex state file", true);
|
|
4385
|
+
return {
|
|
4386
|
+
backend,
|
|
4387
|
+
generation: state.generation,
|
|
4388
|
+
purges: state.purges,
|
|
4389
|
+
queued: state.queued,
|
|
4390
|
+
quarantined: state.quarantined,
|
|
4391
|
+
resumed: existing !== undefined
|
|
4392
|
+
};
|
|
4393
|
+
}
|
|
4394
|
+
testOptions.afterCommitBeforeStateWrite?.();
|
|
4395
|
+
writeState(file, directory, state);
|
|
4396
|
+
if (maxBatches !== undefined && batches >= maxBatches) {
|
|
4397
|
+
return {
|
|
4398
|
+
backend,
|
|
4399
|
+
generation: state.generation,
|
|
4400
|
+
purges: state.purges,
|
|
4401
|
+
queued: state.queued,
|
|
4402
|
+
quarantined: state.quarantined,
|
|
4403
|
+
incomplete: true,
|
|
4404
|
+
resumed: existing !== undefined
|
|
4405
|
+
};
|
|
4406
|
+
}
|
|
4407
|
+
}
|
|
4408
|
+
} finally {
|
|
4409
|
+
try {
|
|
4410
|
+
release();
|
|
4411
|
+
} finally {
|
|
4412
|
+
closeSync3(directory.fd);
|
|
4413
|
+
}
|
|
4414
|
+
}
|
|
4415
|
+
}
|
|
4416
|
+
function acquireOwnerLock(stateFile, directory, testOptions) {
|
|
4417
|
+
const lock = `${stateFile}.lock`;
|
|
4418
|
+
assertReindexOwnerLivenessSupported();
|
|
4419
|
+
const processStart = readReindexProcessStartMarker(process.pid);
|
|
4420
|
+
if (!processStart)
|
|
4421
|
+
throw new Error(`reindex owner liveness is unavailable on ${process.platform}`);
|
|
4422
|
+
const owner = {
|
|
4423
|
+
ownerID: randomUUID7(),
|
|
4424
|
+
pid: process.pid,
|
|
4425
|
+
processStart,
|
|
4426
|
+
hostname: hostname3(),
|
|
4427
|
+
createdAt: Date.now()
|
|
4428
|
+
};
|
|
4429
|
+
for (let attempt = 0;attempt < 3; attempt++) {
|
|
4430
|
+
let fd;
|
|
4431
|
+
try {
|
|
4432
|
+
assertSidecarDirectory(directory);
|
|
4433
|
+
fd = openSync3(lock, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, 384);
|
|
4434
|
+
writeSync2(fd, `${JSON.stringify(owner)}
|
|
4435
|
+
`);
|
|
4436
|
+
fsyncSync3(fd);
|
|
4437
|
+
closeSync3(fd);
|
|
4438
|
+
fd = undefined;
|
|
4439
|
+
assertSidecarDirectory(directory);
|
|
4440
|
+
return () => {
|
|
4441
|
+
const current = readPrivateJsonFile(lock, directory, "reindex owner lock", false);
|
|
4442
|
+
if (!current || !isOwnerMetadata(current.value) || current.value.ownerID !== owner.ownerID) {
|
|
4443
|
+
throw new Error("reindex owner lock changed before release");
|
|
4444
|
+
}
|
|
4445
|
+
removeOwnedPrivateFile(lock, directory, "reindex owner lock", current.identity, owner.ownerID);
|
|
4446
|
+
};
|
|
4447
|
+
} catch (error) {
|
|
4448
|
+
if (fd !== undefined)
|
|
4449
|
+
closeSync3(fd);
|
|
4450
|
+
if (!hasCode(error, "EEXIST") || attempt === 2)
|
|
4451
|
+
throw new Error("reindex already owned");
|
|
4452
|
+
const observed = readPrivateJsonFile(lock, directory, "reindex owner lock", false);
|
|
4453
|
+
if (!observed || !isOwnerMetadata(observed.value))
|
|
4454
|
+
throw new Error("reindex owner lock is unverifiable");
|
|
4455
|
+
const current = observed.value;
|
|
4456
|
+
const pid = current.pid;
|
|
4457
|
+
let alive = false;
|
|
4458
|
+
try {
|
|
4459
|
+
process.kill(pid, 0);
|
|
4460
|
+
alive = true;
|
|
4461
|
+
} catch (probe) {
|
|
4462
|
+
if (!hasCode(probe, "ESRCH"))
|
|
4463
|
+
throw new Error("reindex owner lock is unverifiable");
|
|
4464
|
+
}
|
|
4465
|
+
const status = classifyReindexOwner(current, hostname3(), alive, alive ? readReindexProcessStartMarker(pid) : null);
|
|
4466
|
+
if (status === "live")
|
|
4467
|
+
throw new Error("reindex already owned");
|
|
4468
|
+
if (status === "unverifiable")
|
|
4469
|
+
throw new Error("reindex owner lock is unverifiable");
|
|
4470
|
+
testOptions.beforeStaleLockTakeover?.();
|
|
4471
|
+
quarantineStaleLock(lock, directory, observed.identity, current, owner);
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
throw new Error("reindex already owned");
|
|
4475
|
+
}
|
|
4476
|
+
function readReindexProcessStartMarker(pid, platform3 = process.platform, readers = {}) {
|
|
4477
|
+
if (platform3 === "linux") {
|
|
4478
|
+
try {
|
|
4479
|
+
const value = "readLinuxStat" in readers ? readers.readLinuxStat?.(pid) : readFileSync4(`/proc/${pid}/stat`, "utf8");
|
|
4480
|
+
if (!value)
|
|
4481
|
+
return null;
|
|
4482
|
+
const close = value.lastIndexOf(")");
|
|
4483
|
+
if (close < 0)
|
|
4484
|
+
return null;
|
|
4485
|
+
const start = value.slice(close + 1).trim().split(/\s+/)[19];
|
|
4486
|
+
return start ? `linux:${start}` : null;
|
|
4487
|
+
} catch {
|
|
4488
|
+
return null;
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
if (platform3 === "darwin") {
|
|
4492
|
+
try {
|
|
4493
|
+
const output = "readMacOSPs" in readers ? readers.readMacOSPs?.(pid) : readMacOSProcessStart(pid);
|
|
4494
|
+
return output ? `macos:${output}` : null;
|
|
4495
|
+
} catch {
|
|
4496
|
+
return null;
|
|
4497
|
+
}
|
|
4498
|
+
}
|
|
4499
|
+
return null;
|
|
4500
|
+
}
|
|
4501
|
+
function assertReindexOwnerLivenessSupported(platform3 = process.platform) {
|
|
4502
|
+
if (platform3 !== "linux" && platform3 !== "darwin") {
|
|
4503
|
+
throw new Error(`reindex owner liveness is unsupported on ${platform3}`);
|
|
4504
|
+
}
|
|
4505
|
+
}
|
|
4506
|
+
function readMacOSProcessStart(pid) {
|
|
4507
|
+
const result = spawnSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
|
|
4508
|
+
if (result.status !== 0 || typeof result.stdout !== "string")
|
|
4509
|
+
return null;
|
|
4510
|
+
const marker = result.stdout.trim();
|
|
4511
|
+
return marker || null;
|
|
4512
|
+
}
|
|
4513
|
+
function createState(path, databaseID, backend, databaseFile) {
|
|
4514
|
+
preflightDatabaseIdentity(path, databaseID, databaseFile);
|
|
4515
|
+
const opened = openMemoryDatabase(path);
|
|
4516
|
+
try {
|
|
4517
|
+
assertDatabaseIdentity(path, opened.db, databaseID, databaseFile);
|
|
4518
|
+
const generation = opened.db.query("SELECT COALESCE(MAX(generation), 0) + 1 AS value FROM index_outbox WHERE backend = ?").get(backend).value;
|
|
4519
|
+
return {
|
|
4520
|
+
databaseID,
|
|
4521
|
+
databaseFile,
|
|
4522
|
+
backend,
|
|
4523
|
+
generation,
|
|
4524
|
+
phase: "purges",
|
|
4525
|
+
projectID: null,
|
|
4526
|
+
noteID: null,
|
|
4527
|
+
startedAt: Date.now(),
|
|
4528
|
+
snapshot: randomUUID7(),
|
|
4529
|
+
purges: 0,
|
|
4530
|
+
queued: 0,
|
|
4531
|
+
quarantined: 0
|
|
4532
|
+
};
|
|
4533
|
+
} finally {
|
|
4534
|
+
opened.close();
|
|
4535
|
+
}
|
|
4536
|
+
}
|
|
4537
|
+
function runBatch(path, state, limit, testOptions) {
|
|
4538
|
+
preflightDatabaseIdentity(path, state.databaseID, state.databaseFile);
|
|
4539
|
+
const opened = openMemoryDatabase(path);
|
|
4540
|
+
try {
|
|
4541
|
+
const next = { ...state };
|
|
4542
|
+
testOptions.beforeBatchDatabaseIdentity?.();
|
|
4543
|
+
assertDatabaseIdentity(path, opened.db, state.databaseID, state.databaseFile);
|
|
4544
|
+
opened.db.exec("BEGIN IMMEDIATE");
|
|
4545
|
+
try {
|
|
4546
|
+
if (next.phase === "purges") {
|
|
4547
|
+
const rows = opened.db.query("SELECT id FROM projects WHERE id > ? ORDER BY id LIMIT ?").all(next.projectID ?? "", limit + 1);
|
|
4548
|
+
for (const row of rows.slice(0, limit)) {
|
|
4549
|
+
insert(opened.db, next, "purge-project", row.id, null, null, null);
|
|
4550
|
+
next.purges++;
|
|
4551
|
+
next.projectID = row.id;
|
|
4552
|
+
}
|
|
4553
|
+
if (rows.length <= limit) {
|
|
4554
|
+
next.phase = "notes";
|
|
4555
|
+
next.projectID = null;
|
|
4556
|
+
next.noteID = null;
|
|
4557
|
+
}
|
|
4558
|
+
} else {
|
|
4559
|
+
const rows = opened.db.query("SELECT id, project_id, current_revision, kind, title, summary, content FROM notes WHERE status = 'active' AND (project_id > ? OR (project_id = ? AND id > ?)) ORDER BY project_id, id LIMIT ?").all(next.projectID ?? "", next.projectID ?? "", next.noteID ?? "", limit + 1);
|
|
4560
|
+
for (const row of rows.slice(0, limit)) {
|
|
4561
|
+
next.projectID = row.project_id;
|
|
4562
|
+
next.noteID = row.id;
|
|
4563
|
+
const document = deriveDocument({ projectID: row.project_id, noteID: row.id, revision: row.current_revision, kind: row.kind, title: row.title, summary: row.summary, content: row.content });
|
|
4564
|
+
if (!document) {
|
|
4565
|
+
next.quarantined++;
|
|
4566
|
+
continue;
|
|
4567
|
+
}
|
|
4568
|
+
insert(opened.db, next, "upsert-note", row.project_id, row.id, row.current_revision, document.contentHash);
|
|
4569
|
+
next.queued++;
|
|
4570
|
+
}
|
|
4571
|
+
if (rows.length <= limit) {
|
|
4572
|
+
opened.db.exec("COMMIT");
|
|
4573
|
+
return { state: next, done: true };
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
opened.db.exec("COMMIT");
|
|
4577
|
+
return { state: next, done: false };
|
|
4578
|
+
} catch (error) {
|
|
4579
|
+
try {
|
|
4580
|
+
opened.db.exec("ROLLBACK");
|
|
4581
|
+
} catch {}
|
|
4582
|
+
throw error;
|
|
4583
|
+
}
|
|
4584
|
+
} finally {
|
|
4585
|
+
opened.close();
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4588
|
+
function captureDatabaseFile(path) {
|
|
4589
|
+
const stat = regularFile(path, "database");
|
|
4590
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
4591
|
+
}
|
|
4592
|
+
function assertDatabaseIdentity(path, db, databaseID, expectedFile) {
|
|
4593
|
+
const stat = regularFile(path, "database");
|
|
4594
|
+
if (!sameFile(stat, expectedFile))
|
|
4595
|
+
throw new Error("reindex database file changed");
|
|
4596
|
+
const row = db.query("SELECT database_id FROM agz_meta WHERE id = 1").get();
|
|
4597
|
+
if (row?.database_id !== databaseID)
|
|
4598
|
+
throw new Error("reindex database identity changed");
|
|
4599
|
+
}
|
|
4600
|
+
function preflightDatabaseIdentity(path, databaseID, expectedFile) {
|
|
4601
|
+
if (!sameFile(regularFile(path, "database"), expectedFile)) {
|
|
4602
|
+
throw new Error("reindex database file changed; remove state and restart reindex");
|
|
4603
|
+
}
|
|
4604
|
+
const opened = openReadOnlyMemoryDatabase(path);
|
|
4605
|
+
try {
|
|
4606
|
+
assertDatabaseIdentity(path, opened.db, databaseID, expectedFile);
|
|
4607
|
+
} finally {
|
|
4608
|
+
opened.close();
|
|
4609
|
+
}
|
|
4610
|
+
if (!sameFile(regularFile(path, "database"), expectedFile)) {
|
|
4611
|
+
throw new Error("reindex database file changed; remove state and restart reindex");
|
|
4612
|
+
}
|
|
4613
|
+
}
|
|
4614
|
+
function insert(db, state, operation, projectID, noteID, revision, contentHash) {
|
|
4615
|
+
const key = hashTuple("outbox-operation", 2, [state.backend, operation, projectID, noteID, revision, contentHash, state.generation]);
|
|
4616
|
+
if (db.query("SELECT 1 FROM index_outbox WHERE operation_key = ?").get(key))
|
|
4617
|
+
return;
|
|
4618
|
+
db.query("INSERT INTO index_outbox (backend, operation_key, operation, project_id, note_id, revision, content_hash, generation, lease_generation, fence, state, attempt_count, available_at, heartbeat_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 'pending', 0, ?, NULL, ?)").run(state.backend, key, operation, projectID, noteID, revision, contentHash, state.generation, Date.now(), Date.now());
|
|
4619
|
+
}
|
|
4620
|
+
function openSidecarDirectory(databasePath) {
|
|
4621
|
+
if (process.platform === "win32" || !constants3.O_DIRECTORY || !constants3.O_NOFOLLOW) {
|
|
4622
|
+
throw new Error("reindex sidecar directory security is unsupported on this platform");
|
|
4623
|
+
}
|
|
4624
|
+
const path = `${databasePath}.reindex`;
|
|
4625
|
+
try {
|
|
4626
|
+
mkdirSync4(path, { mode: 448 });
|
|
4627
|
+
} catch (error) {
|
|
4628
|
+
if (!hasCode(error, "EEXIST"))
|
|
4629
|
+
throw error;
|
|
4630
|
+
}
|
|
4631
|
+
const before = lstatSync4(path);
|
|
4632
|
+
if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 63) !== 0) {
|
|
4633
|
+
throw new Error("reindex state directory is unsafe");
|
|
4634
|
+
}
|
|
4635
|
+
const fd = openSync3(path, constants3.O_RDONLY | constants3.O_DIRECTORY | constants3.O_NOFOLLOW);
|
|
4636
|
+
try {
|
|
4637
|
+
const opened = fstatSync2(fd);
|
|
4638
|
+
if (!sameFile(before, opened))
|
|
4639
|
+
throw new Error("reindex state directory changed while opening");
|
|
4640
|
+
const directory = { path, fd, dev: opened.dev, ino: opened.ino };
|
|
4641
|
+
assertSidecarDirectory(directory);
|
|
4642
|
+
return directory;
|
|
4643
|
+
} catch (error) {
|
|
4644
|
+
closeSync3(fd);
|
|
4645
|
+
throw error;
|
|
4646
|
+
}
|
|
4647
|
+
}
|
|
4648
|
+
function assertSidecarDirectory(directory) {
|
|
4649
|
+
const opened = fstatSync2(directory.fd);
|
|
4650
|
+
const current = lstatSync4(directory.path);
|
|
4651
|
+
if (!opened.isDirectory() || !current.isDirectory() || current.isSymbolicLink() || !sameFile(opened, directory) || !sameFile(current, directory) || (current.mode & 63) !== 0) {
|
|
4652
|
+
throw new Error("reindex state directory changed");
|
|
4653
|
+
}
|
|
4654
|
+
}
|
|
4655
|
+
function readState(path, directory, databasePath, databaseID, backend) {
|
|
4656
|
+
const parsed = readPrivateJson(path, directory, "reindex state file", true);
|
|
4657
|
+
if (parsed === undefined)
|
|
4658
|
+
return;
|
|
4659
|
+
if (!parsed || typeof parsed !== "object")
|
|
4660
|
+
throw new Error("reindex state is invalid");
|
|
4661
|
+
const state = parsed;
|
|
4662
|
+
if (state.databaseID !== databaseID || state.backend !== backend || !isFileIdentity(state.databaseFile) || !Number.isSafeInteger(state.generation) || state.generation < 1 || state.phase !== "purges" && state.phase !== "notes" || !Number.isSafeInteger(state.startedAt) || typeof state.snapshot !== "string" || !/^[0-9a-f-]{36}$/i.test(state.snapshot) || !nullableString(state.projectID) || !nullableString(state.noteID) || ![state.purges, state.queued, state.quarantined].every((value) => Number.isSafeInteger(value) && value >= 0)) {
|
|
4663
|
+
throw new Error("reindex state is invalid");
|
|
4664
|
+
}
|
|
4665
|
+
if (!sameFile(regularFile(databasePath, "database"), state.databaseFile)) {
|
|
4666
|
+
throw new Error("reindex state is stale; remove state and restart reindex");
|
|
4667
|
+
}
|
|
4668
|
+
return state;
|
|
4669
|
+
}
|
|
4670
|
+
function readPrivateJson(path, directory, label, optional) {
|
|
4671
|
+
return readPrivateJsonFile(path, directory, label, optional)?.value;
|
|
4672
|
+
}
|
|
4673
|
+
function readPrivateJsonFile(path, directory, label, optional) {
|
|
4674
|
+
let fd;
|
|
4675
|
+
try {
|
|
4676
|
+
assertSidecarDirectory(directory);
|
|
4677
|
+
const before = regularFile(path, label);
|
|
4678
|
+
fd = openSync3(path, constants3.O_RDONLY | constants3.O_NOFOLLOW);
|
|
4679
|
+
const opened = fstatSync2(fd);
|
|
4680
|
+
if (!sameFile(before, opened))
|
|
4681
|
+
throw new Error(`${label} changed while opening`);
|
|
4682
|
+
const raw = readFileSync4(fd, "utf8");
|
|
4683
|
+
const after = regularFile(path, label);
|
|
4684
|
+
if (!sameFile(after, opened))
|
|
4685
|
+
throw new Error(`${label} changed while reading`);
|
|
4686
|
+
assertSidecarDirectory(directory);
|
|
4687
|
+
return { value: JSON.parse(raw), identity: { dev: opened.dev, ino: opened.ino } };
|
|
4688
|
+
} catch (error) {
|
|
4689
|
+
if (optional && hasCode(error, "ENOENT")) {
|
|
4690
|
+
assertSidecarDirectory(directory);
|
|
4691
|
+
return;
|
|
4692
|
+
}
|
|
4693
|
+
throw error;
|
|
4694
|
+
} finally {
|
|
4695
|
+
if (fd !== undefined)
|
|
4696
|
+
closeSync3(fd);
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
function quarantineStaleLock(lock, directory, observedIdentity, observedOwner, contender) {
|
|
4700
|
+
const takeover = `${lock}.takeover`;
|
|
4701
|
+
let takeoverFd;
|
|
4702
|
+
let takeoverIdentity;
|
|
4703
|
+
try {
|
|
4704
|
+
assertSidecarDirectory(directory);
|
|
4705
|
+
takeoverFd = openSync3(takeover, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, 384);
|
|
4706
|
+
const takeoverStat = fstatSync2(takeoverFd);
|
|
4707
|
+
takeoverIdentity = { dev: takeoverStat.dev, ino: takeoverStat.ino };
|
|
4708
|
+
writeSync2(takeoverFd, `${JSON.stringify(contender)}
|
|
4709
|
+
`);
|
|
4710
|
+
fsyncSync3(takeoverFd);
|
|
4711
|
+
closeSync3(takeoverFd);
|
|
4712
|
+
takeoverFd = undefined;
|
|
4713
|
+
const current = readPrivateJsonFile(lock, directory, "reindex owner lock", true);
|
|
4714
|
+
if (!current || !sameFile(current.identity, observedIdentity) || !isOwnerMetadata(current.value) || current.value.ownerID !== observedOwner.ownerID) {
|
|
4715
|
+
return;
|
|
4716
|
+
}
|
|
4717
|
+
const quarantine = `${lock}.${contender.ownerID}.stale`;
|
|
4718
|
+
assertReplacementTargetSafe(quarantine, "reindex stale lock quarantine");
|
|
4719
|
+
renameSync4(lock, quarantine);
|
|
4720
|
+
const claimed = readPrivateJsonFile(quarantine, directory, "reindex stale lock quarantine", false);
|
|
4721
|
+
if (!claimed || !sameFile(claimed.identity, observedIdentity) || !isOwnerMetadata(claimed.value) || claimed.value.ownerID !== observedOwner.ownerID) {
|
|
4722
|
+
throw new Error("reindex owner lock changed during stale takeover");
|
|
4723
|
+
}
|
|
4724
|
+
removeOwnedPrivateFile(quarantine, directory, "reindex stale lock quarantine", observedIdentity, observedOwner.ownerID);
|
|
4725
|
+
} catch (error) {
|
|
4726
|
+
if (hasCode(error, "EEXIST"))
|
|
4727
|
+
throw new Error("reindex already owned");
|
|
4728
|
+
throw error;
|
|
4729
|
+
} finally {
|
|
4730
|
+
if (takeoverFd !== undefined)
|
|
4731
|
+
closeSync3(takeoverFd);
|
|
4732
|
+
if (takeoverIdentity) {
|
|
4733
|
+
removeOwnedPrivateFile(takeover, directory, "reindex stale takeover lock", takeoverIdentity, contender.ownerID);
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
}
|
|
4737
|
+
function removeOwnedPrivateFile(path, directory, label, expectedIdentity, expectedOwnerID) {
|
|
4738
|
+
const current = readPrivateJsonFile(path, directory, label, false);
|
|
4739
|
+
if (!current || !sameFile(current.identity, expectedIdentity) || !isOwnerMetadata(current.value) || current.value.ownerID !== expectedOwnerID) {
|
|
4740
|
+
throw new Error(`${label} changed before removal`);
|
|
4741
|
+
}
|
|
4742
|
+
removePrivateFile(path, directory, label, false);
|
|
4743
|
+
}
|
|
4744
|
+
function writeState(path, directory, state) {
|
|
4745
|
+
const temporary = `${path}.${randomUUID7()}.tmp`;
|
|
4746
|
+
let fd;
|
|
4747
|
+
let created = false;
|
|
4748
|
+
try {
|
|
4749
|
+
assertSidecarDirectory(directory);
|
|
4750
|
+
fd = openSync3(temporary, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, 384);
|
|
4751
|
+
created = true;
|
|
4752
|
+
writeSync2(fd, `${JSON.stringify(state)}
|
|
4753
|
+
`);
|
|
4754
|
+
fsyncSync3(fd);
|
|
4755
|
+
closeSync3(fd);
|
|
4756
|
+
fd = undefined;
|
|
4757
|
+
regularFile(temporary, "reindex temporary state file");
|
|
4758
|
+
assertSidecarDirectory(directory);
|
|
4759
|
+
assertReplacementTargetSafe(path, "reindex state file");
|
|
4760
|
+
renameSync4(temporary, path);
|
|
4761
|
+
created = false;
|
|
4762
|
+
assertSidecarDirectory(directory);
|
|
4763
|
+
fsyncSync3(directory.fd);
|
|
4764
|
+
} finally {
|
|
4765
|
+
if (fd !== undefined)
|
|
4766
|
+
closeSync3(fd);
|
|
4767
|
+
if (created)
|
|
4768
|
+
removePrivateFile(temporary, directory, "reindex temporary state file", true);
|
|
4769
|
+
}
|
|
4770
|
+
}
|
|
4771
|
+
function removePrivateFile(path, directory, label, optional) {
|
|
4772
|
+
try {
|
|
4773
|
+
assertSidecarDirectory(directory);
|
|
4774
|
+
regularFile(path, label);
|
|
4775
|
+
rmSync3(path);
|
|
4776
|
+
assertSidecarDirectory(directory);
|
|
4777
|
+
fsyncSync3(directory.fd);
|
|
4778
|
+
} catch (error) {
|
|
4779
|
+
if (optional && hasCode(error, "ENOENT")) {
|
|
4780
|
+
assertSidecarDirectory(directory);
|
|
4781
|
+
return;
|
|
4782
|
+
}
|
|
4783
|
+
throw error;
|
|
4784
|
+
}
|
|
4785
|
+
}
|
|
4786
|
+
function regularFile(path, label) {
|
|
4787
|
+
const stat = lstatSync4(path);
|
|
4788
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 63) !== 0)
|
|
4789
|
+
throw new Error(`${label} is unsafe`);
|
|
4790
|
+
return stat;
|
|
4791
|
+
}
|
|
4792
|
+
function assertReplacementTargetSafe(path, label) {
|
|
4793
|
+
try {
|
|
4794
|
+
regularFile(path, label);
|
|
4795
|
+
} catch (error) {
|
|
4796
|
+
if (hasCode(error, "ENOENT"))
|
|
4797
|
+
return;
|
|
4798
|
+
throw error;
|
|
4799
|
+
}
|
|
4800
|
+
}
|
|
4801
|
+
function sameFile(left, right) {
|
|
4802
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
4803
|
+
}
|
|
4804
|
+
function isFileIdentity(value) {
|
|
4805
|
+
return Boolean(value && typeof value === "object" && Number.isSafeInteger(value.dev) && Number.isSafeInteger(value.ino));
|
|
4806
|
+
}
|
|
4807
|
+
function nullableString(value) {
|
|
4808
|
+
return value === null || typeof value === "string";
|
|
4809
|
+
}
|
|
4810
|
+
function hasCode(error, code) {
|
|
4811
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
4812
|
+
}
|
|
4813
|
+
function isOwnerMetadata(value) {
|
|
4814
|
+
return Boolean(value && typeof value === "object" && typeof value.ownerID === "string" && value.ownerID && Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.processStart === "string" && value.processStart && typeof value.hostname === "string" && value.hostname && Number.isSafeInteger(value.createdAt));
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4277
4817
|
// src/admin/index.ts
|
|
4278
4818
|
async function runAdmin(argv = process.argv.slice(2)) {
|
|
4279
|
-
const
|
|
4280
|
-
const
|
|
4281
|
-
|
|
4282
|
-
|
|
4819
|
+
const parsed = parseAdminArguments(argv);
|
|
4820
|
+
const configuredDatabasePath = resolveConfig().databasePath;
|
|
4821
|
+
const database = parsed.command === "init" ? undefined : requireExistingDatabase(configuredDatabasePath, parsed.databaseID);
|
|
4822
|
+
const databasePath = database?.path ?? configuredDatabasePath;
|
|
4823
|
+
const [command, subcommand] = [parsed.command, parsed.subcommand];
|
|
4824
|
+
if (command === "init") {
|
|
4825
|
+
const opened = openMemoryDatabase(databasePath);
|
|
4826
|
+
try {
|
|
4827
|
+
return withDatabaseIdentity({ initialized: true }, databaseIdentity(opened.db, databasePath));
|
|
4828
|
+
} finally {
|
|
4829
|
+
opened.close();
|
|
4830
|
+
}
|
|
4831
|
+
}
|
|
4283
4832
|
if (command === "doctor") {
|
|
4284
4833
|
requireExistingDatabase(databasePath);
|
|
4285
4834
|
const opened = openReadOnlyMemoryDatabase(databasePath);
|
|
4286
4835
|
try {
|
|
4287
|
-
return doctorDatabase(opened.db);
|
|
4836
|
+
return withDatabaseIdentity(doctorDatabase(opened.db), database);
|
|
4288
4837
|
} finally {
|
|
4289
4838
|
opened.close();
|
|
4290
4839
|
}
|
|
@@ -4295,7 +4844,7 @@ async function runAdmin(argv = process.argv.slice(2)) {
|
|
|
4295
4844
|
const db = new Database4(databasePath);
|
|
4296
4845
|
try {
|
|
4297
4846
|
const version = schemaVersion(db);
|
|
4298
|
-
return createVerifiedBackup(db, databasePath, version, version, PRODUCT_VERSION);
|
|
4847
|
+
return withDatabaseIdentity(createVerifiedBackup(db, databasePath, version, version, PRODUCT_VERSION), database);
|
|
4299
4848
|
} finally {
|
|
4300
4849
|
db.close();
|
|
4301
4850
|
}
|
|
@@ -4307,7 +4856,7 @@ async function runAdmin(argv = process.argv.slice(2)) {
|
|
|
4307
4856
|
}
|
|
4308
4857
|
const opened = openMemoryDatabase(databasePath);
|
|
4309
4858
|
try {
|
|
4310
|
-
return doctorDatabase(opened.db);
|
|
4859
|
+
return withDatabaseIdentity(doctorDatabase(opened.db), database);
|
|
4311
4860
|
} finally {
|
|
4312
4861
|
opened.close();
|
|
4313
4862
|
}
|
|
@@ -4321,7 +4870,7 @@ async function runAdmin(argv = process.argv.slice(2)) {
|
|
|
4321
4870
|
const confirmation = option(argv, "--confirm");
|
|
4322
4871
|
const expectedHash = option(argv, "--sha256");
|
|
4323
4872
|
if (!confirmation || !expectedHash) {
|
|
4324
|
-
return { dryRun: true, manifest: verified.manifest, targetPath: databasePath };
|
|
4873
|
+
return withDatabaseIdentity({ dryRun: true, manifest: verified.manifest, targetPath: databasePath }, database);
|
|
4325
4874
|
}
|
|
4326
4875
|
if (expectedHash !== verified.manifest.sha256)
|
|
4327
4876
|
throw new Error("restore manifest hash mismatch");
|
|
@@ -4339,7 +4888,7 @@ async function runAdmin(argv = process.argv.slice(2)) {
|
|
|
4339
4888
|
}
|
|
4340
4889
|
return withExclusiveMaintenance(databasePath, verified.manifest.sourceSchema, (maintenance) => {
|
|
4341
4890
|
const preservedPath = restoreVerifiedBackup(manifestPath, databasePath, confirmation, maintenance, expectedHash);
|
|
4342
|
-
return { restored: true, preservedPath, manifest: verified.manifest };
|
|
4891
|
+
return withDatabaseIdentity({ restored: true, preservedPath, manifest: verified.manifest }, database);
|
|
4343
4892
|
}, recovery);
|
|
4344
4893
|
}
|
|
4345
4894
|
if (command === "unlock") {
|
|
@@ -4348,72 +4897,20 @@ async function runAdmin(argv = process.argv.slice(2)) {
|
|
|
4348
4897
|
if (!ownerID || !confirmation)
|
|
4349
4898
|
throw new Error("unlock requires --owner and --confirm");
|
|
4350
4899
|
breakMigrationLock(databasePath, ownerID, confirmation);
|
|
4351
|
-
return { unlocked: true, ownerID };
|
|
4900
|
+
return withDatabaseIdentity({ unlocked: true, ownerID }, database);
|
|
4352
4901
|
}
|
|
4353
4902
|
if (command === "reindex") {
|
|
4354
4903
|
const backend = option(argv, "--backend");
|
|
4355
4904
|
if (!backend || !/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(backend)) {
|
|
4356
4905
|
throw new Error("reindex requires a valid --backend");
|
|
4357
4906
|
}
|
|
4358
|
-
|
|
4359
|
-
try {
|
|
4360
|
-
const now = Date.now();
|
|
4361
|
-
let queued = 0;
|
|
4362
|
-
let purges = 0;
|
|
4363
|
-
const quarantined = {};
|
|
4364
|
-
let generation = 0;
|
|
4365
|
-
const insert = opened.db.query(`
|
|
4366
|
-
INSERT INTO index_outbox
|
|
4367
|
-
(backend, operation_key, operation, project_id, note_id, revision, content_hash,
|
|
4368
|
-
generation, lease_generation, fence, state, attempt_count, available_at,
|
|
4369
|
-
heartbeat_at, created_at)
|
|
4370
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 'pending', 0, ?, NULL, ?)
|
|
4371
|
-
`);
|
|
4372
|
-
opened.db.exec("BEGIN IMMEDIATE");
|
|
4373
|
-
try {
|
|
4374
|
-
generation = opened.db.query("SELECT COALESCE(MAX(generation), 0) + 1 AS generation FROM index_outbox WHERE backend = ?").get(backend).generation;
|
|
4375
|
-
const projects = opened.db.query("SELECT id FROM projects ORDER BY id").all();
|
|
4376
|
-
const notes = opened.db.query("SELECT * FROM notes WHERE status = 'active' ORDER BY project_id, id").all();
|
|
4377
|
-
for (const project of projects) {
|
|
4378
|
-
const operation = "purge-project";
|
|
4379
|
-
const operationKey = outboxOperationKey(backend, operation, project.id, null, null, null, generation);
|
|
4380
|
-
purges += insert.run(backend, operationKey, operation, project.id, null, null, null, generation, now, now).changes;
|
|
4381
|
-
}
|
|
4382
|
-
for (const note of notes) {
|
|
4383
|
-
const document = deriveDocument({
|
|
4384
|
-
projectID: note.project_id,
|
|
4385
|
-
noteID: note.id,
|
|
4386
|
-
revision: note.current_revision,
|
|
4387
|
-
kind: note.kind,
|
|
4388
|
-
title: note.title,
|
|
4389
|
-
summary: note.summary,
|
|
4390
|
-
content: note.content
|
|
4391
|
-
});
|
|
4392
|
-
if (!document) {
|
|
4393
|
-
quarantined.derived_document_unavailable = (quarantined.derived_document_unavailable ?? 0) + 1;
|
|
4394
|
-
continue;
|
|
4395
|
-
}
|
|
4396
|
-
const operation = "upsert-note";
|
|
4397
|
-
const operationKey = outboxOperationKey(backend, operation, note.project_id, note.id, note.current_revision, document.contentHash, generation);
|
|
4398
|
-
queued += insert.run(backend, operationKey, operation, note.project_id, note.id, note.current_revision, document.contentHash, generation, now, now).changes;
|
|
4399
|
-
}
|
|
4400
|
-
opened.db.exec("COMMIT");
|
|
4401
|
-
} catch (error) {
|
|
4402
|
-
try {
|
|
4403
|
-
opened.db.exec("ROLLBACK");
|
|
4404
|
-
} catch {}
|
|
4405
|
-
throw error;
|
|
4406
|
-
}
|
|
4407
|
-
return { backend, generation, purges, queued, quarantined };
|
|
4408
|
-
} finally {
|
|
4409
|
-
opened.close();
|
|
4410
|
-
}
|
|
4907
|
+
return withDatabaseIdentity(runResumableReindex(databasePath, database.databaseID, backend, Number(option(argv, "--batch-size") ?? 100), option(argv, "--max-batches") === undefined ? undefined : Number(option(argv, "--max-batches"))), database);
|
|
4411
4908
|
}
|
|
4412
4909
|
if (command === "outbox" && subcommand === "status") {
|
|
4413
|
-
return withDatabase(databasePath, (db) => ({
|
|
4910
|
+
return withDatabase(databasePath, (db) => withDatabaseIdentity({
|
|
4414
4911
|
states: db.query("SELECT state, COUNT(*) AS count FROM index_outbox GROUP BY state ORDER BY state").all(),
|
|
4415
4912
|
oldestPendingAt: db.query("SELECT MIN(created_at) AS value FROM index_outbox WHERE state IN ('pending','leased')").get().value
|
|
4416
|
-
}));
|
|
4913
|
+
}, database));
|
|
4417
4914
|
}
|
|
4418
4915
|
if (command === "outbox" && subcommand === "retry") {
|
|
4419
4916
|
const id = Number(argv[2]);
|
|
@@ -4423,26 +4920,28 @@ async function runAdmin(argv = process.argv.slice(2)) {
|
|
|
4423
4920
|
const result = db.query(`UPDATE index_outbox
|
|
4424
4921
|
SET state = 'pending', available_at = ?, lease_owner = NULL,
|
|
4425
4922
|
lease_expires_at = NULL, heartbeat_at = NULL,
|
|
4426
|
-
completed_at = NULL, last_error_code = NULL
|
|
4427
|
-
|
|
4428
|
-
|
|
4923
|
+
completed_at = NULL, last_error_code = NULL, attempt_count = 0
|
|
4924
|
+
WHERE id = ? AND state = 'dead'`).run(Date.now(), id);
|
|
4925
|
+
pruneTerminalOutbox(db);
|
|
4926
|
+
return withDatabaseIdentity({ id, retried: result.changes === 1 }, database);
|
|
4429
4927
|
});
|
|
4430
4928
|
}
|
|
4431
4929
|
if (command === "capture" && subcommand === "status") {
|
|
4432
|
-
return withDatabase(databasePath, (db) => ({
|
|
4930
|
+
return withDatabase(databasePath, (db) => withDatabaseIdentity({
|
|
4433
4931
|
events: db.query("SELECT state, COUNT(*) AS count FROM capture_events GROUP BY state ORDER BY state").all(),
|
|
4434
|
-
checkpoints: db.query("SELECT state, COUNT(*) AS count FROM capture_checkpoints GROUP BY state ORDER BY state").all()
|
|
4435
|
-
|
|
4932
|
+
checkpoints: db.query("SELECT state, COUNT(*) AS count FROM capture_checkpoints GROUP BY state ORDER BY state").all(),
|
|
4933
|
+
quarantinePrivacy: quarantinePrivacyReport(db)
|
|
4934
|
+
}, database));
|
|
4436
4935
|
}
|
|
4437
4936
|
if (command === "backup" && subcommand === "prune") {
|
|
4438
4937
|
return withExclusiveMaintenance(databasePath, SCHEMA_VERSION, () => {
|
|
4439
4938
|
const entries = backupEntries(databasePath);
|
|
4440
4939
|
const root = resolve3(`${databasePath}.backup`);
|
|
4441
|
-
const digest =
|
|
4940
|
+
const digest = createHash8("sha256").update(`${resolve3(databasePath)}\x00${root}
|
|
4442
4941
|
${entries.map((entry) => `${basename3(entry.manifest)}\x00${basename3(entry.database)}\x00${entry.sha256}\x00${entry.size}\x00${entry.manifestHash}`).join(`
|
|
4443
4942
|
`)}`).digest("hex");
|
|
4444
4943
|
if (option(argv, "--confirm") !== "DELETE_VERIFIED_BACKUPS") {
|
|
4445
|
-
return { dryRun: true, digest, backups: entries };
|
|
4944
|
+
return withDatabaseIdentity({ dryRun: true, digest, backups: entries }, database);
|
|
4446
4945
|
}
|
|
4447
4946
|
if (option(argv, "--digest") !== digest)
|
|
4448
4947
|
throw new Error("backup prune digest mismatch");
|
|
@@ -4454,14 +4953,59 @@ ${entries.map((entry) => `${basename3(entry.manifest)}\x00${basename3(entry.data
|
|
|
4454
4953
|
return current;
|
|
4455
4954
|
});
|
|
4456
4955
|
for (const current of currentEntries) {
|
|
4457
|
-
|
|
4458
|
-
|
|
4956
|
+
rmSync4(current.database, { force: true });
|
|
4957
|
+
rmSync4(current.manifest, { force: true });
|
|
4459
4958
|
}
|
|
4460
|
-
return { deleted: entries.length, digest };
|
|
4959
|
+
return withDatabaseIdentity({ deleted: entries.length, digest }, database);
|
|
4461
4960
|
});
|
|
4462
4961
|
}
|
|
4463
4962
|
throw new Error(`unknown admin command: ${argv.join(" ")}`);
|
|
4464
4963
|
}
|
|
4964
|
+
var TERMINAL_OUTBOX_RETENTION = 1e4;
|
|
4965
|
+
function parseAdminArguments(argv) {
|
|
4966
|
+
if (argv.length === 0)
|
|
4967
|
+
throw new Error("admin command is required");
|
|
4968
|
+
const [command, subcommand] = argv;
|
|
4969
|
+
const forms = {
|
|
4970
|
+
init: { positional: 0, flags: ["--database-id"] },
|
|
4971
|
+
doctor: { positional: 0, flags: ["--database-id"] },
|
|
4972
|
+
backup: { positional: subcommand === "prune" ? 1 : 0, flags: subcommand === "prune" ? ["--confirm", "--digest", "--database-id"] : ["--database-id"] },
|
|
4973
|
+
upgrade: { positional: 0, flags: ["--to", "--database-id"] },
|
|
4974
|
+
restore: { positional: 1, flags: ["--confirm", "--sha256", "--maintenance-owner", "--maintenance-confirm", "--database-id"] },
|
|
4975
|
+
unlock: { positional: 0, flags: ["--owner", "--confirm", "--database-id"] },
|
|
4976
|
+
reindex: { positional: 0, flags: ["--backend", "--batch-size", "--max-batches", "--database-id"] },
|
|
4977
|
+
outbox: { positional: subcommand === "retry" ? 2 : subcommand === "status" ? 1 : -1, flags: ["--database-id"] },
|
|
4978
|
+
capture: { positional: subcommand === "status" ? 1 : -1, flags: ["--database-id"] }
|
|
4979
|
+
};
|
|
4980
|
+
const form = forms[command];
|
|
4981
|
+
if (!form || form.positional < 0)
|
|
4982
|
+
throw new Error(`unknown admin command: ${argv.join(" ")}`);
|
|
4983
|
+
let positional = command === "restore" ? 2 : subcommand ? 2 : 1;
|
|
4984
|
+
if (command === "backup" && subcommand !== "prune")
|
|
4985
|
+
positional = 1;
|
|
4986
|
+
if (command === "init" || command === "doctor" || command === "upgrade" || command === "unlock" || command === "reindex")
|
|
4987
|
+
positional = 1;
|
|
4988
|
+
if (command === "outbox" && subcommand === "retry")
|
|
4989
|
+
positional = 3;
|
|
4990
|
+
const seen = new Set;
|
|
4991
|
+
for (let index = positional;index < argv.length; index += 2) {
|
|
4992
|
+
const flag = argv[index];
|
|
4993
|
+
const value = argv[index + 1];
|
|
4994
|
+
if (!flag?.startsWith("--") || value === undefined || value.startsWith("--")) {
|
|
4995
|
+
throw new Error("admin arguments must use unique --flag value pairs");
|
|
4996
|
+
}
|
|
4997
|
+
if (!form.flags.includes(flag) || seen.has(flag))
|
|
4998
|
+
throw new Error(`invalid or duplicate admin argument: ${flag}`);
|
|
4999
|
+
seen.add(flag);
|
|
5000
|
+
}
|
|
5001
|
+
if (command === "restore" && (!subcommand || subcommand.startsWith("--"))) {
|
|
5002
|
+
throw new Error("restore manifest path is required");
|
|
5003
|
+
}
|
|
5004
|
+
if ((command === "outbox" || command === "capture") && !subcommand) {
|
|
5005
|
+
throw new Error(`unknown admin command: ${argv.join(" ")}`);
|
|
5006
|
+
}
|
|
5007
|
+
return { command, subcommand, databaseID: option(argv, "--database-id") };
|
|
5008
|
+
}
|
|
4465
5009
|
function withDatabase(databasePath, action) {
|
|
4466
5010
|
const opened = openMemoryDatabase(databasePath);
|
|
4467
5011
|
try {
|
|
@@ -4487,9 +5031,39 @@ function option(argv, name) {
|
|
|
4487
5031
|
const index = argv.indexOf(name);
|
|
4488
5032
|
return index >= 0 ? argv[index + 1] : undefined;
|
|
4489
5033
|
}
|
|
4490
|
-
function requireExistingDatabase(databasePath) {
|
|
5034
|
+
function requireExistingDatabase(databasePath, expectedID) {
|
|
4491
5035
|
if (!existsSync5(databasePath))
|
|
4492
|
-
throw new Error(
|
|
5036
|
+
throw new Error("database does not exist");
|
|
5037
|
+
const stat = lstatSync5(databasePath);
|
|
5038
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
5039
|
+
throw new Error("database must be a regular file");
|
|
5040
|
+
const canonicalPath = realpathSync(databasePath);
|
|
5041
|
+
if (canonicalPath !== resolve3(databasePath))
|
|
5042
|
+
throw new Error("database path must be canonical");
|
|
5043
|
+
const opened = openReadOnlyMemoryDatabase(canonicalPath);
|
|
5044
|
+
try {
|
|
5045
|
+
const identity = databaseIdentity(opened.db, canonicalPath);
|
|
5046
|
+
if (expectedID !== undefined && expectedID !== identity.databaseID)
|
|
5047
|
+
throw new Error("database id mismatch");
|
|
5048
|
+
return identity;
|
|
5049
|
+
} finally {
|
|
5050
|
+
opened.close();
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
function databaseIdentity(db, databasePath) {
|
|
5054
|
+
const row = db.query("SELECT database_id FROM agz_meta WHERE id = 1").get();
|
|
5055
|
+
if (!row?.database_id)
|
|
5056
|
+
throw new Error("database identity is missing");
|
|
5057
|
+
return { path: realpathSync(databasePath), databaseID: row.database_id };
|
|
5058
|
+
}
|
|
5059
|
+
function withDatabaseIdentity(result, identity) {
|
|
5060
|
+
return { ...result, databasePath: identity.path, databaseID: identity.databaseID };
|
|
5061
|
+
}
|
|
5062
|
+
function pruneTerminalOutbox(db) {
|
|
5063
|
+
db.query(`DELETE FROM index_outbox WHERE id IN (
|
|
5064
|
+
SELECT id FROM index_outbox WHERE state IN ('succeeded','dead')
|
|
5065
|
+
ORDER BY completed_at DESC, id DESC LIMIT -1 OFFSET ?
|
|
5066
|
+
)`).run(TERMINAL_OUTBOX_RETENTION);
|
|
4493
5067
|
}
|
|
4494
5068
|
function readSchemaVersion(databasePath) {
|
|
4495
5069
|
const db = new Database4(databasePath, { readonly: true });
|
|
@@ -4521,10 +5095,10 @@ function verifiedBackupEntry(root, manifest) {
|
|
|
4521
5095
|
const candidate = resolve3(manifest);
|
|
4522
5096
|
if (dirname3(candidate) !== root)
|
|
4523
5097
|
throw new Error("backup manifest escaped the backup directory");
|
|
4524
|
-
const stat =
|
|
5098
|
+
const stat = lstatSync5(candidate);
|
|
4525
5099
|
if (!stat.isFile() || stat.isSymbolicLink())
|
|
4526
5100
|
throw new Error("backup manifest must be a regular file");
|
|
4527
|
-
const bytes =
|
|
5101
|
+
const bytes = readFileSync5(candidate);
|
|
4528
5102
|
const verified = verifyBackupManifest(candidate);
|
|
4529
5103
|
if (dirname3(verified.databasePath) !== root) {
|
|
4530
5104
|
throw new Error("backup database escaped the backup directory");
|
|
@@ -4534,20 +5108,9 @@ function verifiedBackupEntry(root, manifest) {
|
|
|
4534
5108
|
database: verified.databasePath,
|
|
4535
5109
|
sha256: verified.manifest.sha256,
|
|
4536
5110
|
size: verified.manifest.size,
|
|
4537
|
-
manifestHash:
|
|
5111
|
+
manifestHash: createHash8("sha256").update(bytes).digest("hex")
|
|
4538
5112
|
};
|
|
4539
5113
|
}
|
|
4540
|
-
function outboxOperationKey(backend, operation, projectID, noteID, revision, contentHash, generation) {
|
|
4541
|
-
return hashTuple("outbox-operation", 2, [
|
|
4542
|
-
backend,
|
|
4543
|
-
operation,
|
|
4544
|
-
projectID,
|
|
4545
|
-
noteID,
|
|
4546
|
-
revision,
|
|
4547
|
-
contentHash,
|
|
4548
|
-
generation
|
|
4549
|
-
]);
|
|
4550
|
-
}
|
|
4551
5114
|
if (import.meta.main) {
|
|
4552
5115
|
try {
|
|
4553
5116
|
const result = await runAdmin();
|