feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
type ModuleStorage,
|
||||
type ModuleStorageBucket,
|
||||
ModuleStorageError,
|
||||
type ModuleStorageListing,
|
||||
type ModuleStorageObject,
|
||||
type ModuleStorageObjectMetadata,
|
||||
type ModuleStorageReadUrl,
|
||||
} from "@ekklesia/module-sdk";
|
||||
|
||||
/**
|
||||
* An in-memory `ModuleStorage`, scoped exactly as the real one is.
|
||||
*
|
||||
* `ModuleStorage` is a `@ekklesia/contracts` interface, so a module-layer test may legitimately
|
||||
* construct one — and doing so demonstrates the portability claim directly: the host supplying this
|
||||
* object could be Ekklesia over S3-compatible storage, or a future standalone host over something
|
||||
* else entirely, and Signage cannot tell.
|
||||
*
|
||||
* It refuses an undeclared bucket exactly as the real facade does, so a test cannot accidentally
|
||||
* pass by reaching a bucket the manifest never declared.
|
||||
*/
|
||||
export interface FakeStorage extends ModuleStorage {
|
||||
readonly objects: Map<string, { bytes: Uint8Array; contentType?: string }>;
|
||||
/** Test-only: make the next `put` for this key fail, so cleanup can be exercised. */
|
||||
failPut(key: string): void;
|
||||
readonly readUrlsIssued: string[];
|
||||
}
|
||||
|
||||
export function createFakeStorage(
|
||||
options: { readonly declared?: readonly string[]; readonly now?: () => Date } = {},
|
||||
): FakeStorage {
|
||||
const declared = new Set(options.declared ?? ["native-images"]);
|
||||
const now = options.now ?? (() => new Date());
|
||||
const objects = new Map<string, { bytes: Uint8Array; contentType?: string }>();
|
||||
const failingPuts = new Set<string>();
|
||||
const readUrlsIssued: string[] = [];
|
||||
|
||||
function makeBucket(id: string): ModuleStorageBucket {
|
||||
return {
|
||||
createReadUrl(key: string): Promise<ModuleStorageReadUrl> {
|
||||
readUrlsIssued.push(key);
|
||||
// Opaque by contract: shape, host, and query are the host's business. A module that parsed
|
||||
// one would be welded to a deployment.
|
||||
return Promise.resolve({
|
||||
expiresAt: new Date(now().getTime() + 15 * 60 * 1000),
|
||||
url: `https://storage.invalid/${encodeURIComponent(key)}?token=opaque`,
|
||||
});
|
||||
},
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
objects.delete(`${id}/${key}`);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
get(key: string): Promise<ModuleStorageObject> {
|
||||
const stored = objects.get(`${id}/${key}`);
|
||||
if (!stored) {
|
||||
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
|
||||
}
|
||||
return Promise.resolve({
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(stored.bytes);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
contentLength: stored.bytes.byteLength,
|
||||
key,
|
||||
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
|
||||
});
|
||||
},
|
||||
|
||||
head(key: string): Promise<ModuleStorageObjectMetadata> {
|
||||
const stored = objects.get(`${id}/${key}`);
|
||||
if (!stored) {
|
||||
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
|
||||
}
|
||||
return Promise.resolve({ contentLength: stored.bytes.byteLength, key });
|
||||
},
|
||||
|
||||
id,
|
||||
|
||||
list(options_?: { readonly prefix?: string }): Promise<ModuleStorageListing> {
|
||||
const prefix = `${id}/${options_?.prefix ?? ""}`;
|
||||
return Promise.resolve({
|
||||
objects: [...objects.entries()]
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([key, value]) => ({
|
||||
contentLength: value.bytes.byteLength,
|
||||
key: key.slice(id.length + 1),
|
||||
})),
|
||||
});
|
||||
},
|
||||
|
||||
put(
|
||||
key: string,
|
||||
body: unknown,
|
||||
putOptions?: { readonly contentType?: string },
|
||||
): Promise<ModuleStorageObjectMetadata> {
|
||||
if (failingPuts.has(key)) {
|
||||
failingPuts.delete(key);
|
||||
return Promise.reject(new ModuleStorageError("STORAGE_PROVIDER_UNAVAILABLE", key));
|
||||
}
|
||||
if (!(body instanceof Uint8Array)) {
|
||||
return Promise.reject(new ModuleStorageError("STORAGE_OPERATION_FAILED", key));
|
||||
}
|
||||
objects.set(`${id}/${key}`, {
|
||||
bytes: body,
|
||||
...(putOptions?.contentType === undefined ? {} : { contentType: putOptions.contentType }),
|
||||
});
|
||||
return Promise.resolve({ contentLength: body.byteLength, key });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bucket(logicalBucketId: string): ModuleStorageBucket {
|
||||
if (!declared.has(logicalBucketId)) {
|
||||
throw new ModuleStorageError("STORAGE_BUCKET_UNDECLARED", logicalBucketId);
|
||||
}
|
||||
return makeBucket(logicalBucketId);
|
||||
},
|
||||
buckets: () => [...declared].sort(),
|
||||
failPut(key: string): void {
|
||||
failingPuts.add(key);
|
||||
},
|
||||
objects,
|
||||
readUrlsIssued,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user