@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
138 lines • 5.6 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { createS3Client, isConditionLost, isNotFound, loadS3Module, streamToBuffer, } from "../../core/s3/client.js";
import { generationOf } from "./types.js";
const decoder = new TextDecoder();
/**
* One JSON-record-per-object client with ETag-anchored CAS.
*
* The generic building block both S3 stores share (mirroring how the
* Redis stores share one cas.lua): a record is one object at
* `{prefix}{name}.json`, and a conditional write anchors on the exact
* ETag the compare-read returned, so the generation check and the
* write hit the same stored version. Immutable-blob planes never need
* this; it exists only for the few mutable control-plane cells.
* Mirrors the Python S3RecordClient.
*/
export class S3RecordClient {
config;
prefix;
clientPromise = null;
constructor(config, prefix) {
this.config = config;
this.prefix = prefix;
}
client() {
this.clientPromise ??= createS3Client(this.config);
return this.clientPromise;
}
key(name) {
return `${this.prefix}${name}.json`;
}
/** Read one record; `[fields, etag]`, `[null, '']` when absent. */
async get(name) {
const [client, mod] = await Promise.all([this.client(), loadS3Module(this.config)]);
let response;
try {
response = await client.send(new mod.GetObjectCommand({ Bucket: this.config.bucket, Key: this.key(name) }));
}
catch (err) {
if (isNotFound(err))
return [null, ''];
throw err;
}
const body = await streamToBuffer(response.Body);
const etag = typeof response.ETag === 'string' ? response.ETag : '';
return [JSON.parse(decoder.decode(body)), etag];
}
async put(name, fields) {
const [client, mod] = await Promise.all([this.client(), loadS3Module(this.config)]);
await client.send(new mod.PutObjectCommand({
Bucket: this.config.bucket,
Key: this.key(name),
Body: JSON.stringify(fields),
}));
}
/**
* Write one record iff its stored generation matches.
*
* Compare-read the record, check the generation client-side, then
* make the write conditional on the exact version read: If-None-Match
* for create (expected 0, nothing stored), If-Match on the read ETag
* otherwise. A 412/409 means another writer moved the record between
* our read and write; the caller adopts and retries.
*/
async casPut(name, fields, expectedGeneration) {
const [stored, etag] = await this.get(name);
if (generationOf(stored) !== expectedGeneration)
return false;
const [client, mod] = await Promise.all([this.client(), loadS3Module(this.config)]);
const condition = stored === null ? { IfNoneMatch: '*' } : { IfMatch: etag };
try {
await client.send(new mod.PutObjectCommand({
Bucket: this.config.bucket,
Key: this.key(name),
Body: JSON.stringify(fields),
...condition,
}));
}
catch (err) {
if (isConditionLost(err))
return false;
throw err;
}
return true;
}
async listNames() {
const [client, mod] = await Promise.all([this.client(), loadS3Module(this.config)]);
const names = [];
let continuationToken;
do {
const response = await client.send(new mod.ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: this.prefix,
...(continuationToken !== undefined ? { ContinuationToken: continuationToken } : {}),
}));
const contents = (response.Contents ?? []);
for (const entry of contents) {
const key = entry.Key.slice(this.prefix.length);
if (key.endsWith('.json'))
names.push(key.slice(0, -'.json'.length));
}
continuationToken =
response.IsTruncated === true ? response.NextContinuationToken : undefined;
} while (continuationToken !== undefined);
return names;
}
async delete(names) {
if (names.length === 0)
return;
const [client, mod] = await Promise.all([this.client(), loadS3Module(this.config)]);
await client.send(new mod.DeleteObjectsCommand({
Bucket: this.config.bucket,
Delete: { Objects: names.map((name) => ({ Key: this.key(name) })) },
}));
}
async clear() {
await this.delete(await this.listNames());
}
async close() {
if (this.clientPromise !== null) {
const client = await this.clientPromise;
client.destroy?.();
this.clientPromise = null;
}
}
}
//# sourceMappingURL=s3.js.map