Files
openmaic/OpenMAIC/lib/persistence/asset-byte-store.ts
2026-08-16 14:58:47 +08:00

92 lines
3.7 KiB
TypeScript

/**
* Which byte layer the server asset registry stores bytes in.
*
* Shared rather than owned by the persistence route, because the offline
* collector must reclaim through the *same* byte layer the route wrote
* through. A collector holding a PostgreSQL byte store while the route writes
* to S3 would drop the blob row and leave the object behind forever, which is
* the leak the collector exists to close.
*/
import { PgAssetByteStore } from '@openmaic/storage/asset/pg-bytes';
import type { AssetByteStore, Queryable } from '@openmaic/storage/asset/pg';
const S3_RESERVED_PREFIXES = ['xn--', 'sthree-', 'amzn-s3-demo-'];
const S3_RESERVED_SUFFIXES = ['-s3alias', '--ol-s3', '.mrap', '--x-s3', '--table-s3'];
/**
* ASSET_S3_BUCKET: a valid bucket name opts asset bytes into S3. The optional
* AWS SDK owns its standard region, credential, and endpoint configuration;
* nothing here reads an AWS environment variable itself.
*
* Validated eagerly and separately from store construction so a caller can
* reject a malformed name before it opens a database connection or resolves
* the optional SDK.
*/
export function configuredS3Bucket(value: string | undefined): string | undefined {
const bucket = value?.trim();
if (!bucket) return undefined;
const invalid =
bucket.length < 3 ||
bucket.length > 63 ||
!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(bucket) ||
bucket.includes('..') ||
/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bucket) ||
S3_RESERVED_PREFIXES.some((prefix) => bucket.startsWith(prefix)) ||
S3_RESERVED_SUFFIXES.some((suffix) => bucket.endsWith(suffix));
if (invalid) {
throw new Error(
'Invalid ASSET_S3_BUCKET: expected a valid Amazon S3 general purpose bucket name',
);
}
return bucket;
}
/**
* The byte store for a configured bucket, or the PostgreSQL byte layer.
*
* This is the only optional import path. The storage package owns both the SDK
* dependency and its ignored native import, so resolution happens from the
* package that declares the peer rather than from this app — and only when a
* bucket is actually configured.
*/
export async function createAssetByteStore(
bucket: string | undefined,
queryable: Queryable,
): Promise<AssetByteStore> {
if (!bucket) return new PgAssetByteStore(queryable);
const storage = await import('@openmaic/storage/asset/s3-bytes');
return storage.loadS3AssetByteStore(bucket);
}
/**
* A byte store whose construction is deferred to first use.
*
* The asset backend is optional, so its configuration must not gate the rest
* of persistence. Awaiting createAssetByteStore during handler initialization
* would let an invalid ASSET_S3_BUCKET, or an AWS SDK that cannot be resolved,
* reject the shared handler and take document and runtime traffic down with
* it. With this wrapper, installed instead, handler initialization never
* touches asset configuration: a misconfiguration fails asset requests and
* only asset requests. A failed construction is not cached, so the next asset
* request retries — the same no-poisoned-singleton rule the route applies to
* its own initialization.
*/
export function lazyAssetByteStore(
bucketValue: string | undefined,
queryable: Queryable,
): AssetByteStore {
let pending: Promise<AssetByteStore> | undefined;
const resolve = (): Promise<AssetByteStore> =>
(pending ??= createAssetByteStore(configuredS3Bucket(bucketValue), queryable).catch(
(error: unknown) => {
pending = undefined;
throw error;
},
));
return {
write: async (hash, bytes) => (await resolve()).write(hash, bytes),
read: async (hash) => (await resolve()).read(hash),
delete: async (hash) => (await resolve()).delete(hash),
};
}