177 lines
6.3 KiB
JavaScript
177 lines
6.3 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.SingleFileMessagePackBackingStore = void 0;
|
|
const promises_1 = require("node:fs/promises");
|
|
const node_path_1 = require("node:path");
|
|
const msgpack_1 = require("@msgpack/msgpack");
|
|
const zod_1 = require("zod");
|
|
const envelopeSchema = zod_1.z.object({
|
|
key: zod_1.z.string().min(1),
|
|
type: zod_1.z.string().min(1),
|
|
payload: zod_1.z.instanceof(Uint8Array),
|
|
storedAt: zod_1.z.string().min(1),
|
|
});
|
|
const envelopeArraySchema = zod_1.z.array(envelopeSchema);
|
|
const legacyEnvelopeArraySchema = zod_1.z.array(zod_1.z.object({
|
|
key: zod_1.z.string().min(1),
|
|
type: zod_1.z.string().min(1),
|
|
payload: zod_1.z.unknown(),
|
|
storedAt: zod_1.z.string().min(1),
|
|
}));
|
|
const transientReplaceErrorCodes = new Set(["EBUSY", "EPERM", "EACCES"]);
|
|
class SingleFileMessagePackBackingStore {
|
|
filePath;
|
|
#writeQueue = Promise.resolve();
|
|
constructor(filePath) {
|
|
this.filePath = (0, node_path_1.resolve)(filePath);
|
|
}
|
|
async pullAll() {
|
|
try {
|
|
const data = await (0, promises_1.readFile)(this.filePath);
|
|
const decoded = legacyEnvelopeArraySchema.parse((0, msgpack_1.decode)(data));
|
|
let repairedLegacyPayload = false;
|
|
const normalized = decoded.map((entry) => {
|
|
const payload = normalizePayload(entry.payload);
|
|
if (payload !== entry.payload) {
|
|
repairedLegacyPayload = true;
|
|
}
|
|
return {
|
|
...entry,
|
|
payload,
|
|
};
|
|
});
|
|
const parsed = envelopeArraySchema.parse(normalized);
|
|
if (repairedLegacyPayload) {
|
|
await this.#writeAll(parsed);
|
|
}
|
|
return parsed;
|
|
}
|
|
catch (error) {
|
|
const code = error.code;
|
|
if (code === "ENOENT") {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
async push(entry) {
|
|
await this.#enqueue(async () => {
|
|
const existing = await this.pullAll();
|
|
const filtered = existing.filter((candidate) => !(candidate.type === entry.type && candidate.key === entry.key));
|
|
filtered.push(entry);
|
|
await this.#writeAll(filtered);
|
|
});
|
|
}
|
|
async delete(entry) {
|
|
await this.#enqueue(async () => {
|
|
const existing = await this.pullAll();
|
|
const filtered = existing.filter((candidate) => !(candidate.type === entry.type && candidate.key === entry.key));
|
|
await this.#writeAll(filtered);
|
|
});
|
|
}
|
|
async pushAll(entries, options = {}) {
|
|
await this.#enqueue(async () => {
|
|
if (options.soft) {
|
|
try {
|
|
await (0, promises_1.readFile)(this.filePath);
|
|
return;
|
|
}
|
|
catch (error) {
|
|
const code = error.code;
|
|
if (code !== "ENOENT") {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
await this.#writeAll(entries);
|
|
});
|
|
}
|
|
async #enqueue(operation) {
|
|
let result;
|
|
const next = this.#writeQueue.then(async () => {
|
|
result = await operation();
|
|
});
|
|
this.#writeQueue = next.then(() => undefined, () => undefined);
|
|
await next;
|
|
return result;
|
|
}
|
|
async #writeAll(entries) {
|
|
await (0, promises_1.mkdir)((0, node_path_1.dirname)(this.filePath), { recursive: true });
|
|
const tempPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${Math.random()
|
|
.toString(36)
|
|
.slice(2)}`;
|
|
try {
|
|
await (0, promises_1.writeFile)(tempPath, (0, msgpack_1.encode)(entries));
|
|
await replaceFileWithRetry(tempPath, this.filePath);
|
|
}
|
|
catch (error) {
|
|
await (0, promises_1.rm)(tempPath, { force: true }).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
exports.SingleFileMessagePackBackingStore = SingleFileMessagePackBackingStore;
|
|
async function replaceFileWithRetry(sourcePath, targetPath) {
|
|
const delays = [25, 75, 150, 300, 600];
|
|
let lastError;
|
|
for (let attempt = 0; attempt <= delays.length; attempt += 1) {
|
|
try {
|
|
await (0, promises_1.rename)(sourcePath, targetPath);
|
|
return;
|
|
}
|
|
catch (error) {
|
|
lastError = error;
|
|
if (!isTransientReplaceError(error) || attempt === delays.length) {
|
|
break;
|
|
}
|
|
await delay(delays[attempt] ?? 0);
|
|
}
|
|
}
|
|
if (isTransientReplaceError(lastError)) {
|
|
try {
|
|
await (0, promises_1.copyFile)(sourcePath, targetPath);
|
|
await (0, promises_1.rm)(sourcePath, { force: true });
|
|
return;
|
|
}
|
|
catch (copyError) {
|
|
throw annotateReplaceError(copyError, sourcePath, targetPath);
|
|
}
|
|
}
|
|
throw annotateReplaceError(lastError, sourcePath, targetPath);
|
|
}
|
|
function isTransientReplaceError(error) {
|
|
return isErrnoException(error) &&
|
|
typeof error.code === "string" &&
|
|
transientReplaceErrorCodes.has(error.code);
|
|
}
|
|
function annotateReplaceError(error, sourcePath, targetPath) {
|
|
if (error instanceof Error) {
|
|
error.message = `CultCache failed to replace ${targetPath} from ${sourcePath}: ${error.message}`;
|
|
return error;
|
|
}
|
|
return new Error(`CultCache failed to replace ${targetPath} from ${sourcePath}: ${String(error)}`);
|
|
}
|
|
function delay(milliseconds) {
|
|
return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
|
|
}
|
|
function normalizePayload(payload) {
|
|
if (payload instanceof Uint8Array) {
|
|
return payload;
|
|
}
|
|
if (isObject(payload) &&
|
|
payload.type === "Buffer" &&
|
|
Array.isArray(payload.data) &&
|
|
payload.data.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) {
|
|
return Uint8Array.from(payload.data);
|
|
}
|
|
if (Array.isArray(payload) && payload.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) {
|
|
return Uint8Array.from(payload);
|
|
}
|
|
return (0, msgpack_1.encode)(payload);
|
|
}
|
|
function isObject(value) {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
function isErrnoException(error) {
|
|
return typeof error === "object" && error !== null && "code" in error;
|
|
}
|