@@ -0,0 +1,18 @@
|
||||
import { MemoryKvPrimitives } from "../../plugos/lib/memory_kv_primitives.ts";
|
||||
import { assertEquals } from "../../test_deps.ts";
|
||||
import { ChunkedKvStoreSpacePrimitives } from "./chunked_datastore_space_primitives.ts";
|
||||
import { testSpacePrimitives } from "./space_primitives.test.ts";
|
||||
|
||||
Deno.test("chunked_datastore_space_primitives", async () => {
|
||||
const memoryKv = new MemoryKvPrimitives();
|
||||
// In memory store and tiny chunks for testing
|
||||
const spacePrimitives = new ChunkedKvStoreSpacePrimitives(memoryKv, 5);
|
||||
await testSpacePrimitives(spacePrimitives);
|
||||
const [deletedChunk] = await memoryKv.batchGet([[
|
||||
"content",
|
||||
"test.bin",
|
||||
"000",
|
||||
]]);
|
||||
// This one was deleted during the test (but here we're checking the underlying store for content)
|
||||
assertEquals(deletedChunk, undefined);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { SpacePrimitives } from "./space_primitives.ts";
|
||||
import { KvKey } from "$sb/types.ts";
|
||||
import { KvPrimitives } from "../../plugos/lib/kv_primitives.ts";
|
||||
import { KvMetaSpacePrimitives } from "./kv_meta_space_primitives.ts";
|
||||
import { PrefixedKvPrimitives } from "../../plugos/lib/prefixed_kv_primitives.ts";
|
||||
|
||||
/**
|
||||
* A space primitives implementation that stores files in chunks in a KV store.
|
||||
* This is useful for KV stores that have a size limit per value, such as DenoKV.
|
||||
* Meta data will be kept with a "meta" prefix and content will be kept with a "content" prefix
|
||||
* Example use with DenoKV:
|
||||
* const denoKv = new DenoKvPrimitives(await Deno.openKv());
|
||||
* const spacePrimitives = new ChunkedDataStoreSpacePrimitives(denoKv, 65536); // max 64kb per chunk
|
||||
*/
|
||||
export class ChunkedKvStoreSpacePrimitives extends KvMetaSpacePrimitives {
|
||||
/**
|
||||
* @param baseKv the underlying kv primitives (not prefixed with e.g. meta and content)
|
||||
* @param chunkSize
|
||||
* @param metaPrefix
|
||||
* @param contentPrefix
|
||||
*/
|
||||
constructor(
|
||||
baseKv: KvPrimitives,
|
||||
chunkSize: number,
|
||||
metaPrefix = ["meta"],
|
||||
contentPrefix = ["content"],
|
||||
) {
|
||||
// Super call with a metaPrefix for storing the file metadata
|
||||
super(new PrefixedKvPrimitives(baseKv, metaPrefix), {
|
||||
async readFile(name: string, spacePrimitives: SpacePrimitives) {
|
||||
const meta = await spacePrimitives.getFileMeta(name);
|
||||
|
||||
// Buffer to store the concatenated chunks
|
||||
const concatenatedChunks = new Uint8Array(meta.size);
|
||||
let offset = 0;
|
||||
// Implicit assumption, chunks are ordered by chunk id by the underlying store
|
||||
for await (
|
||||
const { value } of baseKv.query({
|
||||
prefix: [...contentPrefix, name],
|
||||
})
|
||||
) {
|
||||
concatenatedChunks.set(value, offset);
|
||||
offset += value.length;
|
||||
}
|
||||
|
||||
return concatenatedChunks;
|
||||
},
|
||||
async writeFile(
|
||||
name: string,
|
||||
data: Uint8Array,
|
||||
) {
|
||||
// Persist the data, chunk by chunk
|
||||
let chunkId = 0;
|
||||
for (let i = 0; i < data.byteLength; i += chunkSize) {
|
||||
const chunk = data.slice(i, i + chunkSize);
|
||||
await baseKv.batchSet([{
|
||||
// "3 digits ought to be enough for anybody" — famous last words
|
||||
key: [...contentPrefix, name, String(chunkId).padStart(3, "0")],
|
||||
value: chunk,
|
||||
}]);
|
||||
chunkId++;
|
||||
}
|
||||
},
|
||||
async deleteFile(name: string, spacePrimitives: SpacePrimitives) {
|
||||
const fileMeta = await spacePrimitives.getFileMeta(name);
|
||||
// Using this we can calculate the chunk keys
|
||||
const keysToDelete: KvKey[] = [];
|
||||
let chunkId = 0;
|
||||
for (let i = 0; i < fileMeta.size; i += chunkSize) {
|
||||
keysToDelete.push([
|
||||
...contentPrefix,
|
||||
name,
|
||||
String(chunkId).padStart(3, "0"),
|
||||
]);
|
||||
chunkId++;
|
||||
}
|
||||
return baseKv.batchDelete(keysToDelete);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import "https://esm.sh/fake-indexeddb@4.0.2/auto";
|
||||
import { assertEquals } from "../../test_deps.ts";
|
||||
import { DataStore } from "../../plugos/lib/datastore.ts";
|
||||
import { IndexedDBKvPrimitives } from "../../plugos/lib/indexeddb_kv_primitives.ts";
|
||||
import { DataStoreSpacePrimitives } from "./datastore_space_primitives.ts";
|
||||
import { testSpacePrimitives } from "./space_primitives.test.ts";
|
||||
|
||||
Deno.test("DataStoreSpacePrimitives", {
|
||||
sanitizeResources: false,
|
||||
@@ -12,34 +12,6 @@ Deno.test("DataStoreSpacePrimitives", {
|
||||
await db.init();
|
||||
|
||||
const space = new DataStoreSpacePrimitives(new DataStore(db));
|
||||
const files = await space.fetchFileList();
|
||||
assertEquals(files, []);
|
||||
// Write text file
|
||||
const fileMeta = await space.writeFile(
|
||||
"test.txt",
|
||||
stringToBytes("Hello World"),
|
||||
);
|
||||
assertEquals(
|
||||
(await space.readFile("test.txt")).data,
|
||||
stringToBytes("Hello World"),
|
||||
);
|
||||
const fbContent = (await space.readFile("test.txt"))
|
||||
.data;
|
||||
assertEquals(new TextDecoder().decode(fbContent), "Hello World");
|
||||
assertEquals(await space.fetchFileList(), [fileMeta]);
|
||||
const buf = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
// Write binary file
|
||||
await space.writeFile("test.bin", buf);
|
||||
const fMeta = await space.getFileMeta("test.bin");
|
||||
assertEquals(fMeta.size, 5);
|
||||
assertEquals((await space.fetchFileList()).length, 2);
|
||||
|
||||
await space.deleteFile("test.bin");
|
||||
assertEquals(await space.fetchFileList(), [fileMeta]);
|
||||
|
||||
await testSpacePrimitives(space);
|
||||
db.close();
|
||||
});
|
||||
|
||||
function stringToBytes(str: string): Uint8Array {
|
||||
return new TextEncoder().encode(str);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ export type FileContent = {
|
||||
const filesMetaPrefix = ["file", "meta"];
|
||||
const filesContentPrefix = ["file", "content"];
|
||||
|
||||
/**
|
||||
* TODO: Replace this with ChunkedDatastoreSpacePrimitives
|
||||
*/
|
||||
export class DataStoreSpacePrimitives implements SpacePrimitives {
|
||||
constructor(
|
||||
private ds: DataStore,
|
||||
@@ -46,14 +49,27 @@ export class DataStoreSpacePrimitives implements SpacePrimitives {
|
||||
_selfUpdate?: boolean,
|
||||
suggestedMeta?: FileMeta,
|
||||
): Promise<FileMeta> {
|
||||
const meta: FileMeta = {
|
||||
name,
|
||||
created: suggestedMeta?.lastModified || Date.now(),
|
||||
lastModified: suggestedMeta?.lastModified || Date.now(),
|
||||
contentType: mime.getType(name) || "application/octet-stream",
|
||||
size: data.byteLength,
|
||||
perm: suggestedMeta?.perm || "rw",
|
||||
};
|
||||
let meta: FileMeta | undefined;
|
||||
try {
|
||||
// Build off of the existing file meta, if file exists
|
||||
meta = await this.getFileMeta(name);
|
||||
} catch {
|
||||
// Not found, that's fine
|
||||
}
|
||||
if (!meta) {
|
||||
meta = {
|
||||
name,
|
||||
created: suggestedMeta?.lastModified || Date.now(),
|
||||
perm: suggestedMeta?.perm || "rw",
|
||||
contentType: mime.getType(name) || "application/octet-stream",
|
||||
// Overwritten in a sec
|
||||
lastModified: 0,
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
meta.lastModified = suggestedMeta?.lastModified || Date.now();
|
||||
meta.size = data.byteLength;
|
||||
|
||||
await this.ds.batchSet<FileMeta | FileContent>([
|
||||
{
|
||||
key: [...filesContentPrefix, name],
|
||||
|
||||
@@ -1,31 +1,12 @@
|
||||
import { assertEquals } from "../../test_deps.ts";
|
||||
import { DenoKVSpacePrimitives } from "./deno_kv_space_primitives.ts";
|
||||
import { DenoKvPrimitives } from "../../plugos/lib/deno_kv_primitives.ts";
|
||||
import { ChunkedKvStoreSpacePrimitives } from "./chunked_datastore_space_primitives.ts";
|
||||
import { testSpacePrimitives } from "./space_primitives.test.ts";
|
||||
|
||||
Deno.test("deno_kv_space_primitives", async () => {
|
||||
Deno.test("deno kv test", async () => {
|
||||
const tempFile = await Deno.makeTempFile({ suffix: ".db" });
|
||||
const spacePrimitives = new DenoKVSpacePrimitives();
|
||||
await spacePrimitives.init(tempFile);
|
||||
await spacePrimitives.writeFile("test.txt", new TextEncoder().encode("test"));
|
||||
let result = await spacePrimitives.readFile("test.txt");
|
||||
assertEquals(result.data, new TextEncoder().encode("test"));
|
||||
let listing = await spacePrimitives.fetchFileList();
|
||||
assertEquals(listing.length, 1);
|
||||
await spacePrimitives.writeFile(
|
||||
"test.txt",
|
||||
new TextEncoder().encode("test2"),
|
||||
);
|
||||
result = await spacePrimitives.readFile("test.txt");
|
||||
assertEquals(result.data, new TextEncoder().encode("test2"));
|
||||
await spacePrimitives.deleteFile("test.txt");
|
||||
listing = await spacePrimitives.fetchFileList();
|
||||
try {
|
||||
await spacePrimitives.readFile("test.txt");
|
||||
throw new Error("Should not be here");
|
||||
} catch (e: any) {
|
||||
assertEquals(e.message, "Not found");
|
||||
}
|
||||
assertEquals(listing.length, 0);
|
||||
|
||||
spacePrimitives.close();
|
||||
const denoKv = new DenoKvPrimitives(await Deno.openKv(tempFile));
|
||||
const spacePrimitives = new ChunkedKvStoreSpacePrimitives(denoKv, 65536);
|
||||
await testSpacePrimitives(spacePrimitives);
|
||||
denoKv.close();
|
||||
await Deno.remove(tempFile);
|
||||
});
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/// <reference lib="deno.unstable" />
|
||||
|
||||
import { FileMeta } from "$sb/types.ts";
|
||||
import type { SpacePrimitives } from "./space_primitives.ts";
|
||||
import { mime } from "https://deno.land/x/mimetypes@v1.0.0/mod.ts";
|
||||
|
||||
export class DenoKVSpacePrimitives implements SpacePrimitives {
|
||||
private kv!: Deno.Kv;
|
||||
private dataAttribute = "file";
|
||||
private metaAttribute = "meta";
|
||||
|
||||
async init(path?: string) {
|
||||
this.kv = await Deno.openKv(path);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.kv.close();
|
||||
}
|
||||
|
||||
async fetchFileList(): Promise<FileMeta[]> {
|
||||
const results: FileMeta[] = [];
|
||||
for await (
|
||||
const result of this.kv.list({
|
||||
prefix: [this.metaAttribute],
|
||||
})
|
||||
) {
|
||||
results.push(result.value as FileMeta);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
async readFile(name: string): Promise<{ data: Uint8Array; meta: FileMeta }> {
|
||||
const [meta, data] = await this.kv.getMany([[this.metaAttribute, name], [
|
||||
this.dataAttribute,
|
||||
name,
|
||||
]]);
|
||||
if (!meta.value) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
return {
|
||||
data: data.value as Uint8Array,
|
||||
meta: meta.value as FileMeta,
|
||||
};
|
||||
}
|
||||
async getFileMeta(name: string): Promise<FileMeta> {
|
||||
const result = await this.kv.get([this.metaAttribute, name]);
|
||||
if (result.value) {
|
||||
return result.value as FileMeta;
|
||||
} else {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
}
|
||||
async writeFile(
|
||||
name: string,
|
||||
data: Uint8Array,
|
||||
_selfUpdate?: boolean | undefined,
|
||||
suggestedMeta?: FileMeta | undefined,
|
||||
): Promise<FileMeta> {
|
||||
const meta: FileMeta = {
|
||||
name,
|
||||
created: suggestedMeta?.created || Date.now(),
|
||||
lastModified: suggestedMeta?.lastModified || Date.now(),
|
||||
contentType: mime.getType(name) || "application/octet-stream",
|
||||
size: data.byteLength,
|
||||
perm: suggestedMeta?.perm || "rw",
|
||||
};
|
||||
const res = await this.kv.atomic()
|
||||
.set([this.dataAttribute, name], data)
|
||||
.set([this.metaAttribute, name], meta)
|
||||
.commit();
|
||||
if (!res.ok) {
|
||||
throw res;
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
async deleteFile(name: string): Promise<void> {
|
||||
const res = await this.kv.atomic()
|
||||
.delete([this.dataAttribute, name])
|
||||
.delete([this.metaAttribute, name])
|
||||
.commit();
|
||||
if (!res.ok) {
|
||||
throw res;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export class EventedSpacePrimitives implements SpacePrimitives {
|
||||
constructor(
|
||||
private wrapped: SpacePrimitives,
|
||||
private eventHook: EventHook,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
dispatchEvent(name: string, ...args: any[]): Promise<any[]> {
|
||||
return this.eventHook.dispatchEvent(name, ...args);
|
||||
|
||||
@@ -6,6 +6,7 @@ export class HttpSpacePrimitives implements SpacePrimitives {
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly expectedSpacePath?: string,
|
||||
private bearerToken?: string,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -20,6 +21,12 @@ export class HttpSpacePrimitives implements SpacePrimitives {
|
||||
...options.headers,
|
||||
"X-Sync-Mode": "true",
|
||||
};
|
||||
if (this.bearerToken) {
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
"Authorization": `Bearer ${this.bearerToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetch(url, options);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { FileMeta } from "$sb/types.ts";
|
||||
import { KvPrimitives } from "../../plugos/lib/kv_primitives.ts";
|
||||
import { mime } from "../deps.ts";
|
||||
import { SpacePrimitives } from "./space_primitives.ts";
|
||||
|
||||
export type KvMetaSpacePrimitivesCallbacks = {
|
||||
readFile: (
|
||||
name: string,
|
||||
spacePrimitives: SpacePrimitives,
|
||||
) => Promise<Uint8Array>;
|
||||
writeFile: (
|
||||
name: string,
|
||||
data: Uint8Array,
|
||||
spacePrimitives: SpacePrimitives,
|
||||
) => Promise<void>;
|
||||
deleteFile: (name: string, spacePrimitives: SpacePrimitives) => Promise<void>;
|
||||
};
|
||||
|
||||
export class KvMetaSpacePrimitives implements SpacePrimitives {
|
||||
constructor(
|
||||
protected kv: KvPrimitives,
|
||||
private callbacks: KvMetaSpacePrimitivesCallbacks,
|
||||
) {
|
||||
}
|
||||
|
||||
async readFile(
|
||||
name: string,
|
||||
): Promise<{ data: Uint8Array; meta: FileMeta }> {
|
||||
const [data, [meta]] = await Promise.all([
|
||||
this.callbacks.readFile(name, this),
|
||||
this.kv.batchGet([[name]]),
|
||||
]);
|
||||
return { data, meta: meta };
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
name: string,
|
||||
data: Uint8Array,
|
||||
_selfUpdate?: boolean | undefined,
|
||||
desiredMeta?: FileMeta | undefined,
|
||||
): Promise<FileMeta> {
|
||||
let meta: FileMeta | undefined;
|
||||
try {
|
||||
// Build off of the existing file meta, if file exists
|
||||
meta = await this.getFileMeta(name);
|
||||
} catch {
|
||||
// Not found, that's fine
|
||||
}
|
||||
if (!meta) {
|
||||
meta = {
|
||||
name,
|
||||
perm: "rw",
|
||||
created: Date.now(),
|
||||
contentType: mime.getType(name) || "application/octet-stream",
|
||||
// These will be overwritten in a bit
|
||||
lastModified: 0,
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
meta = {
|
||||
...meta,
|
||||
lastModified: desiredMeta?.lastModified || Date.now(),
|
||||
size: data.byteLength,
|
||||
};
|
||||
await Promise.all([
|
||||
this.callbacks.writeFile(name, data, this),
|
||||
this.kv.batchSet([{ key: [name], value: meta }]),
|
||||
]);
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
async deleteFile(name: string): Promise<void> {
|
||||
await Promise.all([
|
||||
this.callbacks.deleteFile(name, this),
|
||||
this.kv.batchDelete([[name]]),
|
||||
]);
|
||||
}
|
||||
|
||||
async fetchFileList(): Promise<FileMeta[]> {
|
||||
const files: FileMeta[] = [];
|
||||
for await (const meta of this.kv.query({})) {
|
||||
files.push(meta.value);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async getFileMeta(name: string): Promise<FileMeta> {
|
||||
const fileMeta = (await this.kv.batchGet([[name]]))[0];
|
||||
if (!fileMeta) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
return fileMeta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { S3SpacePrimitives } from "./s3_space_primitives.ts";
|
||||
import { MemoryKvPrimitives } from "../../plugos/lib/memory_kv_primitives.ts";
|
||||
import { testSpacePrimitives } from "./space_primitives.test.ts";
|
||||
|
||||
Deno.test("s3_space_primitives", async () => {
|
||||
return;
|
||||
const options = {
|
||||
accessKey: Deno.env.get("AWS_ACCESS_KEY_ID")!,
|
||||
secretKey: Deno.env.get("AWS_SECRET_ACCESS_KEY")!,
|
||||
endPoint: Deno.env.get("AWS_ENDPOINT")!,
|
||||
region: Deno.env.get("AWS_REGION")!,
|
||||
bucket: Deno.env.get("AWS_BUCKET")!,
|
||||
};
|
||||
|
||||
const primitives = new S3SpacePrimitives(
|
||||
new MemoryKvPrimitives(),
|
||||
["meta"],
|
||||
"test",
|
||||
options,
|
||||
);
|
||||
await testSpacePrimitives(primitives);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// We're explicitly using 0.4.0 to be able to hijack the path encoding, which is inconsisently broken in 0.5.0
|
||||
import { S3Client } from "https://deno.land/x/s3_lite_client@0.4.0/mod.ts";
|
||||
import type { ClientOptions } from "https://deno.land/x/s3_lite_client@0.4.0/client.ts";
|
||||
import { KvMetaSpacePrimitives } from "./kv_meta_space_primitives.ts";
|
||||
import { KvPrimitives } from "../../plugos/lib/kv_primitives.ts";
|
||||
import { mime } from "../deps.ts";
|
||||
import { KV, KvKey } from "$sb/types.ts";
|
||||
import { PrefixedKvPrimitives } from "../../plugos/lib/prefixed_kv_primitives.ts";
|
||||
|
||||
export type S3SpacePrimitivesOptions = ClientOptions;
|
||||
|
||||
/**
|
||||
* Because S3 cannot store arbitrary metadata (well it can, but you cannot retrieve it when listing objects), we need to store it in a separate KV store
|
||||
*/
|
||||
export class S3SpacePrimitives extends KvMetaSpacePrimitives {
|
||||
client: S3Client;
|
||||
objectPrefix: string;
|
||||
constructor(
|
||||
baseKv: KvPrimitives,
|
||||
metaPrefix: KvKey,
|
||||
objectPrefix: string,
|
||||
options: S3SpacePrimitivesOptions,
|
||||
) {
|
||||
const client = new S3Client(options);
|
||||
super(new PrefixedKvPrimitives(baseKv, metaPrefix), {
|
||||
async readFile(
|
||||
name: string,
|
||||
): Promise<Uint8Array> {
|
||||
try {
|
||||
const obj = await client.getObject(encodePath(objectPrefix + name));
|
||||
return new Uint8Array(await obj.arrayBuffer());
|
||||
} catch (e: any) {
|
||||
console.error("Got S3 error", e.message);
|
||||
|
||||
if (e.message.includes("does not exist")) {
|
||||
throw new Error(`Not found`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
async writeFile(
|
||||
name: string,
|
||||
data: Uint8Array,
|
||||
): Promise<void> {
|
||||
await client.putObject(encodePath(objectPrefix + name), data);
|
||||
},
|
||||
async deleteFile(name: string): Promise<void> {
|
||||
await client.deleteObject(encodePath(objectPrefix + name));
|
||||
},
|
||||
});
|
||||
this.client = client;
|
||||
this.objectPrefix = objectPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all objects from S3 bucket, finds any missing files and adds them to the KV store
|
||||
* Doesn't delete items, nor update any existing items
|
||||
*/
|
||||
async syncFileList(): Promise<void> {
|
||||
const currentFiles = await this.fetchFileList();
|
||||
const entriesToAdd: KV[] = [];
|
||||
for await (
|
||||
const objectData of this.client.listObjects({
|
||||
prefix: this.objectPrefix,
|
||||
})
|
||||
) {
|
||||
// Find the file meta for this object
|
||||
let fileMeta = currentFiles.find((f) =>
|
||||
f.name === decodePath(objectData.key.slice(this.objectPrefix.length))
|
||||
);
|
||||
if (fileMeta) {
|
||||
// Exists, continue
|
||||
continue;
|
||||
}
|
||||
fileMeta = {
|
||||
name: decodePath(objectData.key.slice(this.objectPrefix.length)),
|
||||
created: objectData.lastModified.getTime(),
|
||||
lastModified: objectData.lastModified.getTime(),
|
||||
contentType: mime.getType(objectData.key) || "application/octet-stream",
|
||||
size: objectData.size,
|
||||
perm: "rw",
|
||||
};
|
||||
console.log("Adding file metadata to KV", fileMeta.name);
|
||||
entriesToAdd.push({
|
||||
key: [fileMeta.name],
|
||||
value: fileMeta,
|
||||
});
|
||||
}
|
||||
return this.kv.batchSet(entriesToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
// Stolen from https://github.com/aws/aws-sdk-js/blob/master/lib/util.js
|
||||
|
||||
function uriEscapePath(string: string): string {
|
||||
return string.split("/").map(uriEscape).join("/");
|
||||
}
|
||||
|
||||
function uriEscape(string: string): string {
|
||||
let output = encodeURIComponent(string);
|
||||
output = output.replace(/[^A-Za-z0-9_.~\-%]+/g, escape);
|
||||
|
||||
// AWS percent-encodes some extra non-standard characters in a URI
|
||||
output = output.replace(/[*]/g, function (ch) {
|
||||
return "%" + ch.charCodeAt(0).toString(16).toUpperCase();
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function encodePath(name: string): string {
|
||||
return uriEscapePath(name);
|
||||
}
|
||||
function decodePath(encoded: string): string {
|
||||
// AWS only returns ' replace with '
|
||||
return encoded.replaceAll("'", "'");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { assert, assertEquals } from "../../test_deps.ts";
|
||||
import { SpacePrimitives } from "./space_primitives.ts";
|
||||
|
||||
export async function testSpacePrimitives(spacePrimitives: SpacePrimitives) {
|
||||
const files = await spacePrimitives.fetchFileList();
|
||||
assertEquals(files, []);
|
||||
// Write text file
|
||||
const fileMeta = await spacePrimitives.writeFile(
|
||||
"test.txt",
|
||||
stringToBytes("Hello World"),
|
||||
false,
|
||||
{
|
||||
name: "test.txt",
|
||||
perm: "rw",
|
||||
created: 10,
|
||||
contentType: "text/plain",
|
||||
lastModified: 20,
|
||||
size: 11,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: retrievedData, meta: retrievedMeta } = await spacePrimitives
|
||||
.readFile("test.txt");
|
||||
|
||||
assertEquals(retrievedData, stringToBytes("Hello World"));
|
||||
// Check that the meta data is persisted
|
||||
assertEquals(retrievedMeta.lastModified, 20);
|
||||
|
||||
const fbContent = (await spacePrimitives.readFile("test.txt"))
|
||||
.data;
|
||||
assertEquals(new TextDecoder().decode(fbContent), "Hello World");
|
||||
|
||||
assertEquals(await spacePrimitives.fetchFileList(), [fileMeta]);
|
||||
const buf = new Uint8Array(1024 * 1024);
|
||||
buf.set([1, 2, 3, 4, 5]);
|
||||
// Write binary file
|
||||
await spacePrimitives.writeFile("test.bin", buf);
|
||||
const fMeta = await spacePrimitives.getFileMeta("test.bin");
|
||||
assertEquals(fMeta.size, 1024 * 1024);
|
||||
assertEquals((await spacePrimitives.fetchFileList()).length, 2);
|
||||
// console.log(spacePrimitives);
|
||||
|
||||
await spacePrimitives.deleteFile("test.bin");
|
||||
assertEquals(await spacePrimitives.fetchFileList(), [fileMeta]);
|
||||
|
||||
// Clean up
|
||||
await spacePrimitives.deleteFile("test.txt");
|
||||
assertEquals(await spacePrimitives.fetchFileList(), []);
|
||||
|
||||
// Test weird file names
|
||||
await spacePrimitives.writeFile("test+'s.txt", stringToBytes("Hello world!"));
|
||||
assertEquals(
|
||||
stringToBytes("Hello world!"),
|
||||
(await spacePrimitives.readFile("test+'s.txt")).data,
|
||||
);
|
||||
await spacePrimitives.deleteFile("test+'s.txt");
|
||||
|
||||
// Check deletion of weird file file name
|
||||
try {
|
||||
await spacePrimitives.getFileMeta("test+'s.txt");
|
||||
assert(false);
|
||||
} catch (e: any) {
|
||||
assertEquals(e.message, "Not found");
|
||||
}
|
||||
}
|
||||
|
||||
function stringToBytes(str: string): Uint8Array {
|
||||
return new TextEncoder().encode(str);
|
||||
}
|
||||
@@ -1,21 +1,26 @@
|
||||
// export type FileEncoding = "utf8" | "arraybuffer" | "dataurl";
|
||||
// export type FileData = ArrayBuffer | string;
|
||||
|
||||
import { FileMeta } from "$sb/types.ts";
|
||||
import type { FileMeta } from "$sb/types.ts";
|
||||
|
||||
/**
|
||||
* A generic interface used by `Space` to interact with the underlying storage, designed to be easy to implement for different storage backends
|
||||
*/
|
||||
export interface SpacePrimitives {
|
||||
// Returns a list of file meta data as well as the timestamp of this snapshot
|
||||
fetchFileList(): Promise<FileMeta[]>;
|
||||
|
||||
// The result of this should be consistent with the result of fetchFileList for this entry
|
||||
getFileMeta(name: string): Promise<FileMeta>;
|
||||
|
||||
readFile(
|
||||
name: string,
|
||||
): Promise<{ data: Uint8Array; meta: FileMeta }>;
|
||||
getFileMeta(name: string): Promise<FileMeta>;
|
||||
|
||||
writeFile(
|
||||
name: string,
|
||||
data: Uint8Array,
|
||||
// Used to decide whether or not to emit change events
|
||||
selfUpdate?: boolean,
|
||||
// May be ignored, but ideally should be used to set the lastModified time
|
||||
meta?: FileMeta,
|
||||
): Promise<FileMeta>;
|
||||
|
||||
deleteFile(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user