storybooker 0.19.3 → 0.22.0-canary.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -18
- package/dist/adapters/_internal/queue.d.mts +127 -0
- package/dist/aws-dynamodb.d.mts +22 -0
- package/dist/aws-dynamodb.mjs +118 -0
- package/dist/aws-dynamodb.mjs.map +1 -0
- package/dist/aws-s3.d.mts +20 -0
- package/dist/aws-s3.mjs +96 -0
- package/dist/aws-s3.mjs.map +1 -0
- package/dist/azure-blob-storage.d.mts +20 -0
- package/dist/azure-blob-storage.mjs +126 -0
- package/dist/azure-blob-storage.mjs.map +1 -0
- package/dist/azure-cosmos-db.d.mts +23 -0
- package/dist/azure-cosmos-db.mjs +87 -0
- package/dist/azure-cosmos-db.mjs.map +1 -0
- package/dist/azure-data-tables.d.mts +23 -0
- package/dist/azure-data-tables.mjs +127 -0
- package/dist/azure-data-tables.mjs.map +1 -0
- package/dist/azure-easy-auth.d.mts +50 -0
- package/dist/azure-easy-auth.mjs +88 -0
- package/dist/azure-easy-auth.mjs.map +1 -0
- package/dist/azure-functions.d.mts +62 -0
- package/dist/azure-functions.mjs +147 -0
- package/dist/azure-functions.mjs.map +1 -0
- package/dist/fs.d.mts +37 -0
- package/dist/fs.mjs +240 -0
- package/dist/fs.mjs.map +1 -0
- package/dist/gcp-big-table.d.mts +23 -0
- package/dist/gcp-big-table.mjs +92 -0
- package/dist/gcp-big-table.mjs.map +1 -0
- package/dist/gcp-firestore.d.mts +22 -0
- package/dist/gcp-firestore.mjs +87 -0
- package/dist/gcp-firestore.mjs.map +1 -0
- package/dist/gcp-storage.d.mts +20 -0
- package/dist/gcp-storage.mjs +96 -0
- package/dist/gcp-storage.mjs.map +1 -0
- package/dist/handlers/handle-process-zip.mjs +90 -0
- package/dist/handlers/handle-process-zip.mjs.map +1 -0
- package/dist/handlers/handle-purge.d.mts +12 -0
- package/dist/handlers/handle-purge.mjs +36 -0
- package/dist/handlers/handle-purge.mjs.map +1 -0
- package/dist/handlers/handle-serve-storybook.mjs +94 -0
- package/dist/handlers/handle-serve-storybook.mjs.map +1 -0
- package/dist/index.d.mts +28 -0
- package/dist/index.mjs +62 -0
- package/dist/index.mjs.map +1 -0
- package/dist/models/builds-model.mjs +248 -0
- package/dist/models/builds-model.mjs.map +1 -0
- package/dist/models/builds-schema.d.mts +171 -0
- package/dist/models/builds-schema.mjs +67 -0
- package/dist/models/builds-schema.mjs.map +1 -0
- package/dist/models/projects-model.mjs +122 -0
- package/dist/models/projects-model.mjs.map +1 -0
- package/dist/models/projects-schema.d.mts +70 -0
- package/dist/models/projects-schema.mjs +37 -0
- package/dist/models/projects-schema.mjs.map +1 -0
- package/dist/models/tags-model.mjs +110 -0
- package/dist/models/tags-model.mjs.map +1 -0
- package/dist/models/tags-schema.d.mts +76 -0
- package/dist/models/tags-schema.mjs +34 -0
- package/dist/models/tags-schema.mjs.map +1 -0
- package/dist/models/~model.mjs +43 -0
- package/dist/models/~model.mjs.map +1 -0
- package/dist/models/~shared-schema.d.mts +1 -0
- package/dist/models/~shared-schema.mjs +20 -0
- package/dist/models/~shared-schema.mjs.map +1 -0
- package/dist/mysql.d.mts +39 -0
- package/dist/mysql.mjs +151 -0
- package/dist/mysql.mjs.map +1 -0
- package/dist/redis.d.mts +33 -0
- package/dist/redis.mjs +118 -0
- package/dist/redis.mjs.map +1 -0
- package/dist/routers/account-router.mjs +91 -0
- package/dist/routers/account-router.mjs.map +1 -0
- package/dist/routers/builds-router.mjs +347 -0
- package/dist/routers/builds-router.mjs.map +1 -0
- package/dist/routers/projects-router.mjs +236 -0
- package/dist/routers/projects-router.mjs.map +1 -0
- package/dist/routers/root-router.mjs +108 -0
- package/dist/routers/root-router.mjs.map +1 -0
- package/dist/routers/tags-router.mjs +269 -0
- package/dist/routers/tags-router.mjs.map +1 -0
- package/dist/routers/tasks-router.mjs +71 -0
- package/dist/routers/tasks-router.mjs.map +1 -0
- package/dist/urls.d.mts +47 -0
- package/dist/urls.mjs +208 -0
- package/dist/urls.mjs.map +1 -0
- package/dist/utils/adapter-utils.d.mts +14 -0
- package/dist/utils/adapter-utils.mjs +14 -0
- package/dist/utils/adapter-utils.mjs.map +1 -0
- package/dist/utils/auth.mjs +25 -0
- package/dist/utils/auth.mjs.map +1 -0
- package/dist/utils/error.d.mts +21 -0
- package/dist/utils/error.mjs +109 -0
- package/dist/utils/error.mjs.map +1 -0
- package/dist/utils/file-utils.mjs +16 -0
- package/dist/utils/file-utils.mjs.map +1 -0
- package/dist/utils/openapi-utils.mjs +45 -0
- package/dist/utils/openapi-utils.mjs.map +1 -0
- package/dist/utils/request.mjs +35 -0
- package/dist/utils/request.mjs.map +1 -0
- package/dist/utils/response.mjs +24 -0
- package/dist/utils/response.mjs.map +1 -0
- package/dist/utils/store.mjs +54 -0
- package/dist/utils/store.mjs.map +1 -0
- package/dist/utils/ui-utils.mjs +38 -0
- package/dist/utils/ui-utils.mjs.map +1 -0
- package/dist/utils/url-utils.d.mts +10 -0
- package/dist/utils/url-utils.mjs +54 -0
- package/dist/utils/url-utils.mjs.map +1 -0
- package/dist/~internal/adapter/auth.d.mts +123 -0
- package/dist/~internal/adapter/auth.mjs +20 -0
- package/dist/~internal/adapter/auth.mjs.map +1 -0
- package/dist/~internal/adapter/database.d.mts +240 -0
- package/dist/~internal/adapter/database.mjs +63 -0
- package/dist/~internal/adapter/database.mjs.map +1 -0
- package/dist/~internal/adapter/logger.d.mts +34 -0
- package/dist/~internal/adapter/logger.mjs +13 -0
- package/dist/~internal/adapter/logger.mjs.map +1 -0
- package/dist/~internal/adapter/storage.d.mts +208 -0
- package/dist/~internal/adapter/storage.mjs +63 -0
- package/dist/~internal/adapter/storage.mjs.map +1 -0
- package/dist/~internal/adapter/ui.d.mts +109 -0
- package/dist/~internal/adapter/ui.mjs +1 -0
- package/dist/~internal/adapter.d.mts +8 -0
- package/dist/~internal/adapter.mjs +6 -0
- package/dist/~internal/constants.d.mts +24 -0
- package/dist/~internal/constants.mjs +32 -0
- package/dist/~internal/constants.mjs.map +1 -0
- package/dist/~internal/mimes.d.mts +449 -0
- package/dist/~internal/mimes.mjs +454 -0
- package/dist/~internal/mimes.mjs.map +1 -0
- package/dist/~internal/router.d.mts +1651 -0
- package/dist/~internal/router.mjs +39 -0
- package/dist/~internal/router.mjs.map +1 -0
- package/dist/~internal/types.d.mts +77 -0
- package/dist/~internal/types.mjs +1 -0
- package/dist/~internal/utils.d.mts +4 -0
- package/dist/~internal/utils.mjs +5 -0
- package/openapi.json +3162 -0
- package/package.json +148 -27
- package/src/adapters/_internal/auth.ts +135 -0
- package/src/adapters/_internal/database.ts +241 -0
- package/src/adapters/_internal/index.ts +8 -0
- package/src/adapters/_internal/logger.ts +41 -0
- package/src/adapters/_internal/queue.ts +151 -0
- package/src/adapters/_internal/storage.ts +197 -0
- package/src/adapters/_internal/ui.ts +103 -0
- package/src/adapters/aws-dynamodb.ts +201 -0
- package/src/adapters/aws-s3.ts +160 -0
- package/src/adapters/azure-blob-storage.ts +223 -0
- package/src/adapters/azure-cosmos-db.ts +158 -0
- package/src/adapters/azure-data-tables.ts +223 -0
- package/src/adapters/azure-easy-auth.ts +174 -0
- package/src/adapters/azure-functions.ts +242 -0
- package/src/adapters/fs.ts +398 -0
- package/src/adapters/gcp-big-table.ts +157 -0
- package/src/adapters/gcp-firestore.ts +146 -0
- package/src/adapters/gcp-storage.ts +141 -0
- package/src/adapters/mysql.ts +296 -0
- package/src/adapters/redis.ts +242 -0
- package/src/handlers/handle-process-zip.ts +117 -0
- package/src/handlers/handle-purge.ts +65 -0
- package/src/handlers/handle-serve-storybook.ts +101 -0
- package/src/index.ts +81 -16
- package/src/mocks/mock-auth-service.ts +51 -0
- package/src/mocks/mock-store.ts +26 -0
- package/src/models/builds-model.ts +373 -0
- package/src/models/builds-schema.ts +84 -0
- package/src/models/projects-model.ts +177 -0
- package/src/models/projects-schema.ts +69 -0
- package/src/models/tags-model.ts +138 -0
- package/src/models/tags-schema.ts +45 -0
- package/src/models/~model.ts +79 -0
- package/src/models/~shared-schema.ts +14 -0
- package/src/routers/_app-router.ts +57 -0
- package/src/routers/account-router.ts +136 -0
- package/src/routers/builds-router.ts +464 -0
- package/src/routers/projects-router.ts +309 -0
- package/src/routers/root-router.ts +127 -0
- package/src/routers/tags-router.ts +339 -0
- package/src/routers/tasks-router.ts +75 -0
- package/src/types.ts +107 -0
- package/src/urls.ts +327 -0
- package/src/utils/adapter-utils.ts +26 -0
- package/src/utils/auth.test.ts +71 -0
- package/src/utils/auth.ts +39 -0
- package/src/utils/constants.ts +31 -0
- package/src/utils/date-utils.ts +10 -0
- package/src/utils/error.test.ts +86 -0
- package/src/utils/error.ts +140 -0
- package/src/utils/file-utils.test.ts +65 -0
- package/src/utils/file-utils.ts +43 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/mime-utils.ts +457 -0
- package/src/utils/openapi-utils.ts +49 -0
- package/src/utils/request.ts +97 -0
- package/src/utils/response.ts +20 -0
- package/src/utils/store.ts +85 -0
- package/src/utils/story-utils.ts +42 -0
- package/src/utils/text-utils.ts +10 -0
- package/src/utils/ui-utils.ts +57 -0
- package/src/utils/url-utils.ts +113 -0
- package/dist/index.js +0 -554
- package/src/commands/create.ts +0 -263
- package/src/commands/purge.ts +0 -70
- package/src/commands/test.ts +0 -42
- package/src/service-schema.d.ts +0 -2023
- package/src/utils/auth-utils.ts +0 -31
- package/src/utils/pkg-utils.ts +0 -37
- package/src/utils/sb-build.ts +0 -55
- package/src/utils/sb-test.ts +0 -115
- package/src/utils/schema-utils.ts +0 -123
- package/src/utils/stream-utils.ts +0 -72
- package/src/utils/types.ts +0 -4
- package/src/utils/zip.ts +0 -77
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { File, Storage } from "@google-cloud/storage";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import type streamWeb from "node:stream/web";
|
|
5
|
+
import { StorageAdapterErrors, type StorageAdapter } from "./_internal/storage.ts";
|
|
6
|
+
|
|
7
|
+
export class GcpGcsStorageService implements StorageAdapter {
|
|
8
|
+
#client: Storage;
|
|
9
|
+
|
|
10
|
+
constructor(client: Storage) {
|
|
11
|
+
this.#client = client;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
metadata: StorageAdapter["metadata"] = { name: "Google Cloud Storage" };
|
|
15
|
+
|
|
16
|
+
createContainer: StorageAdapter["createContainer"] = async (containerId, _options) => {
|
|
17
|
+
try {
|
|
18
|
+
const bucketName = genBucketNameFromContainerId(containerId);
|
|
19
|
+
await this.#client.createBucket(bucketName, {});
|
|
20
|
+
} catch (error) {
|
|
21
|
+
throw new StorageAdapterErrors.ContainerAlreadyExistsError(containerId, error);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
deleteContainer: StorageAdapter["deleteContainer"] = async (containerId, _options) => {
|
|
26
|
+
try {
|
|
27
|
+
const bucketName = genBucketNameFromContainerId(containerId);
|
|
28
|
+
await this.#client.bucket(bucketName).delete();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new StorageAdapterErrors.ContainerDoesNotExistError(containerId, error);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
hasContainer: StorageAdapter["hasContainer"] = async (containerId, _options) => {
|
|
35
|
+
const bucketName = genBucketNameFromContainerId(containerId);
|
|
36
|
+
const [exists] = await this.#client.bucket(bucketName).exists();
|
|
37
|
+
return exists;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
listContainers: StorageAdapter["listContainers"] = async (_options) => {
|
|
41
|
+
const [buckets] = await this.#client.getBuckets();
|
|
42
|
+
return buckets.map((bucket) => bucket.name);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
deleteFiles: StorageAdapter["deleteFiles"] = async (containerId, filePathsOrPrefix, _options) => {
|
|
46
|
+
const bucketName = genBucketNameFromContainerId(containerId);
|
|
47
|
+
const bucket = this.#client.bucket(bucketName);
|
|
48
|
+
|
|
49
|
+
if (typeof filePathsOrPrefix === "string") {
|
|
50
|
+
// Delete all files with the prefix
|
|
51
|
+
await bucket.deleteFiles({ prefix: filePathsOrPrefix });
|
|
52
|
+
} else {
|
|
53
|
+
// Delete specific files
|
|
54
|
+
await Promise.all(
|
|
55
|
+
filePathsOrPrefix.map(
|
|
56
|
+
async (filepath) => await bucket.file(filepath).delete({ ignoreNotFound: true }),
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
uploadFiles: StorageAdapter["uploadFiles"] = async (containerId, files, _options) => {
|
|
63
|
+
const bucketName = genBucketNameFromContainerId(containerId);
|
|
64
|
+
const bucket = this.#client.bucket(bucketName);
|
|
65
|
+
|
|
66
|
+
await Promise.allSettled(
|
|
67
|
+
files.map(async ({ content, path, mimeType }) => {
|
|
68
|
+
await uploadFileToGcs(bucket.file(path), content, mimeType);
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
hasFile: StorageAdapter["hasFile"] = async (containerId, filepath, _options) => {
|
|
74
|
+
const bucketName = genBucketNameFromContainerId(containerId);
|
|
75
|
+
const file = this.#client.bucket(bucketName).file(filepath);
|
|
76
|
+
const [exists] = await file.exists();
|
|
77
|
+
return exists;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
downloadFile: StorageAdapter["downloadFile"] = async (containerId, filepath, _options) => {
|
|
81
|
+
const bucketName = genBucketNameFromContainerId(containerId);
|
|
82
|
+
const file = this.#client.bucket(bucketName).file(filepath);
|
|
83
|
+
|
|
84
|
+
const [exists] = await file.exists();
|
|
85
|
+
if (!exists) {
|
|
86
|
+
throw new StorageAdapterErrors.FileDoesNotExistError(containerId, filepath);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const [metadata] = await file.getMetadata();
|
|
90
|
+
const mimeType = metadata.contentType;
|
|
91
|
+
|
|
92
|
+
const readable = file.createReadStream();
|
|
93
|
+
const content = Readable.toWeb(readable);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
content: content as ReadableStream,
|
|
97
|
+
mimeType,
|
|
98
|
+
path: filepath,
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function genBucketNameFromContainerId(containerId: string): string {
|
|
104
|
+
// GCS bucket names: lowercase, numbers, dashes, dots, 3-63 chars
|
|
105
|
+
return containerId
|
|
106
|
+
.replaceAll(/[^\w.-]+/g, "-")
|
|
107
|
+
.replaceAll(/^-+|-+$/g, "")
|
|
108
|
+
.toLowerCase()
|
|
109
|
+
.slice(0, 63);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function uploadFileToGcs(
|
|
113
|
+
file: File,
|
|
114
|
+
data: Blob | string | ReadableStream,
|
|
115
|
+
mimeType: string,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
if (typeof data === "string" || data instanceof Buffer) {
|
|
118
|
+
await file.save(data, { contentType: mimeType });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (data instanceof Blob) {
|
|
123
|
+
const buffer = Buffer.from(await data.arrayBuffer());
|
|
124
|
+
await file.save(buffer, { contentType: mimeType });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const readable =
|
|
129
|
+
data instanceof ReadableStream ? Readable.fromWeb(data as streamWeb.ReadableStream) : data;
|
|
130
|
+
|
|
131
|
+
if (readable instanceof Readable) {
|
|
132
|
+
// Node.js Readable stream
|
|
133
|
+
await new Promise<void>((resolve, reject) => {
|
|
134
|
+
const writeStream = file.createWriteStream({ contentType: mimeType });
|
|
135
|
+
readable.pipe(writeStream).on("finish", resolve).on("error", reject);
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
throw new Error(`Unknown file type`);
|
|
141
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// oxlint-disable max-lines-per-function
|
|
2
|
+
// oxlint-disable no-unsafe-member-access
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DatabaseAdapterErrors,
|
|
6
|
+
type DatabaseAdapter,
|
|
7
|
+
type DatabaseAdapterOptions,
|
|
8
|
+
type DatabaseDocumentListOptions,
|
|
9
|
+
type StoryBookerDatabaseDocument,
|
|
10
|
+
} from "./_internal/database.ts";
|
|
11
|
+
|
|
12
|
+
// Define a generic SQL connection interface that works with multiple MySQL libraries
|
|
13
|
+
export interface MySQLConnection {
|
|
14
|
+
connect?(): Promise<void>;
|
|
15
|
+
execute<Data>(query: string, params?: unknown[]): Promise<[Data]>;
|
|
16
|
+
query<Data>(query: string, params?: unknown[]): Promise<[Data]>;
|
|
17
|
+
end?(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* MySQL database adapter for StoryBooker.
|
|
22
|
+
* Uses tables to represent collections and rows to represent documents.
|
|
23
|
+
*
|
|
24
|
+
* Table structure:
|
|
25
|
+
* - Each collection becomes a table with name: `sb_{collectionId}`
|
|
26
|
+
* - Each table has columns: id (VARCHAR PRIMARY KEY), data (JSON), created_at (TIMESTAMP), updated_at (TIMESTAMP)
|
|
27
|
+
* - Collections metadata stored in `sb_collections` table
|
|
28
|
+
*
|
|
29
|
+
* Compatible with mysql2, mysql, and other MySQL libraries that implement the connection interface.
|
|
30
|
+
*/
|
|
31
|
+
export class MySQLDatabaseAdapter implements DatabaseAdapter {
|
|
32
|
+
#connection: MySQLConnection;
|
|
33
|
+
#tablePrefix: string;
|
|
34
|
+
|
|
35
|
+
constructor(connection: MySQLConnection, tablePrefix = "sb") {
|
|
36
|
+
this.#connection = connection;
|
|
37
|
+
this.#tablePrefix = tablePrefix;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
metadata: DatabaseAdapter["metadata"] = { name: "MySQL" };
|
|
41
|
+
|
|
42
|
+
init: DatabaseAdapter["init"] = async (_options) => {
|
|
43
|
+
// Create collections metadata table
|
|
44
|
+
try {
|
|
45
|
+
const collectionsTable = `${this.#tablePrefix}_collections`;
|
|
46
|
+
await this.#connection.connect?.();
|
|
47
|
+
await this.#connection.execute(`
|
|
48
|
+
CREATE TABLE IF NOT EXISTS \`${collectionsTable}\` (
|
|
49
|
+
id VARCHAR(255) PRIMARY KEY,
|
|
50
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
51
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
52
|
+
`);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new DatabaseAdapterErrors.DatabaseNotInitializedError(error);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Helper methods
|
|
59
|
+
#getTableName(collectionId: string): string {
|
|
60
|
+
return `${this.#tablePrefix}_${collectionId}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#getCollectionsTableName(): string {
|
|
64
|
+
return `${this.#tablePrefix}_collections`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// oxlint-disable-next-line class-methods-use-this
|
|
68
|
+
#formatDocumentRow<Document extends StoryBookerDatabaseDocument>(row: { id: string }): Document {
|
|
69
|
+
if ("data" in row && typeof row["data"] === "string") {
|
|
70
|
+
return { id: row.id, ...JSON.parse(row["data"]) } as Document;
|
|
71
|
+
}
|
|
72
|
+
return row as unknown as Document;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
listCollections: DatabaseAdapter["listCollections"] = async (_options) => {
|
|
76
|
+
const collectionsTable = this.#getCollectionsTableName();
|
|
77
|
+
const [rows] = await this.#connection.execute<StoryBookerDatabaseDocument[]>(
|
|
78
|
+
`SELECT id FROM \`${collectionsTable}\``,
|
|
79
|
+
);
|
|
80
|
+
return rows.map((row) => row.id);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
createCollection: DatabaseAdapter["createCollection"] = async (collectionId, _options) => {
|
|
84
|
+
try {
|
|
85
|
+
const tableName = this.#getTableName(collectionId);
|
|
86
|
+
const collectionsTable = this.#getCollectionsTableName();
|
|
87
|
+
|
|
88
|
+
// Create the collection table
|
|
89
|
+
await this.#connection.execute(`
|
|
90
|
+
CREATE TABLE IF NOT EXISTS \`${tableName}\` (
|
|
91
|
+
id VARCHAR(255) PRIMARY KEY,
|
|
92
|
+
data JSON NOT NULL,
|
|
93
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
94
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
95
|
+
INDEX idx_created_at (created_at)
|
|
96
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
97
|
+
`);
|
|
98
|
+
|
|
99
|
+
// Register collection
|
|
100
|
+
await this.#connection.execute(`INSERT IGNORE INTO \`${collectionsTable}\` (id) VALUES (?)`, [
|
|
101
|
+
collectionId,
|
|
102
|
+
]);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new DatabaseAdapterErrors.CollectionAlreadyExistsError(collectionId, error);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
hasCollection: DatabaseAdapter["hasCollection"] = async (collectionId, _options) => {
|
|
109
|
+
const collectionsTable = this.#getCollectionsTableName();
|
|
110
|
+
const [rows] = await this.#connection.execute<StoryBookerDatabaseDocument[]>(
|
|
111
|
+
`SELECT 1 FROM \`${collectionsTable}\` WHERE id = ? LIMIT 1`,
|
|
112
|
+
[collectionId],
|
|
113
|
+
);
|
|
114
|
+
return rows.length > 0;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
deleteCollection: DatabaseAdapter["deleteCollection"] = async (collectionId, _options) => {
|
|
118
|
+
const tableName = this.#getTableName(collectionId);
|
|
119
|
+
const collectionsTable = this.#getCollectionsTableName();
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Drop the table
|
|
123
|
+
await this.#connection.execute(`DROP TABLE IF EXISTS \`${tableName}\``);
|
|
124
|
+
|
|
125
|
+
// Remove from collections registry
|
|
126
|
+
await this.#connection.execute(`DELETE FROM \`${collectionsTable}\` WHERE id = ?`, [
|
|
127
|
+
collectionId,
|
|
128
|
+
]);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId, error);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
listDocuments: DatabaseAdapter["listDocuments"] = async <
|
|
135
|
+
Document extends StoryBookerDatabaseDocument,
|
|
136
|
+
>(
|
|
137
|
+
collectionId: string,
|
|
138
|
+
listOptions: DatabaseDocumentListOptions<Document>,
|
|
139
|
+
_options: DatabaseAdapterOptions,
|
|
140
|
+
): Promise<Document[]> => {
|
|
141
|
+
const tableName = this.#getTableName(collectionId);
|
|
142
|
+
|
|
143
|
+
let query = `SELECT id, data FROM \`${tableName}\``;
|
|
144
|
+
const params: unknown[] = [];
|
|
145
|
+
|
|
146
|
+
// Apply string-based filtering (basic WHERE clause support)
|
|
147
|
+
if (listOptions?.filter && typeof listOptions.filter === "string") {
|
|
148
|
+
query += ` WHERE ${listOptions.filter}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Apply sorting
|
|
152
|
+
if (listOptions?.sort && listOptions.sort === "latest") {
|
|
153
|
+
query += ` ORDER BY created_at DESC`;
|
|
154
|
+
|
|
155
|
+
// Function-based sorting will be applied in memory
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Apply limit
|
|
159
|
+
if (listOptions?.limit && listOptions.limit > 0) {
|
|
160
|
+
query += ` LIMIT ?`;
|
|
161
|
+
params.push(listOptions.limit);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const [rows] = await this.#connection.execute<Document[]>(query, params);
|
|
165
|
+
let documents: Document[] = [];
|
|
166
|
+
for (const row of rows) {
|
|
167
|
+
documents.push(this.#formatDocumentRow<Document>(row));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Apply function-based filtering
|
|
171
|
+
const filterFn =
|
|
172
|
+
listOptions?.filter && typeof listOptions.filter === "function"
|
|
173
|
+
? listOptions.filter
|
|
174
|
+
: undefined;
|
|
175
|
+
if (filterFn) {
|
|
176
|
+
documents = documents.filter((item) => filterFn(item));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Apply function-based sorting
|
|
180
|
+
if (listOptions?.sort && typeof listOptions.sort === "function") {
|
|
181
|
+
documents.sort(listOptions.sort);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Apply field selection (projection)
|
|
185
|
+
if (listOptions?.select && listOptions.select.length > 0) {
|
|
186
|
+
documents = documents.map((doc) => {
|
|
187
|
+
const projected = { id: doc.id } as Document;
|
|
188
|
+
// oxlint-disable-next-line no-non-null-assertion
|
|
189
|
+
for (const field of listOptions.select!) {
|
|
190
|
+
if (field in doc) {
|
|
191
|
+
// oxlint-disable-next-line no-explicit-any
|
|
192
|
+
(projected as any)[field] = (doc as any)[field];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return projected;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return documents;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
getDocument: DatabaseAdapter["getDocument"] = async <
|
|
203
|
+
Document extends StoryBookerDatabaseDocument,
|
|
204
|
+
>(
|
|
205
|
+
collectionId: string,
|
|
206
|
+
documentId: string,
|
|
207
|
+
_options: DatabaseAdapterOptions,
|
|
208
|
+
): Promise<Document> => {
|
|
209
|
+
const tableName = this.#getTableName(collectionId);
|
|
210
|
+
const [rows] = await this.#connection.execute<Document[]>(
|
|
211
|
+
`SELECT id, data FROM \`${tableName}\` WHERE id = ? LIMIT 1`,
|
|
212
|
+
[documentId],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const [row] = rows;
|
|
216
|
+
if (!row) {
|
|
217
|
+
throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return this.#formatDocumentRow<Document>(row);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
hasDocument: DatabaseAdapter["hasDocument"] = async (collectionId, documentId, _options) => {
|
|
224
|
+
const tableName = this.#getTableName(collectionId);
|
|
225
|
+
const [rows] = await this.#connection.execute<Document[]>(
|
|
226
|
+
`SELECT 1 FROM \`${tableName}\` WHERE id = ? LIMIT 1`,
|
|
227
|
+
[documentId],
|
|
228
|
+
);
|
|
229
|
+
return rows.length > 0;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
createDocument: DatabaseAdapter["createDocument"] = async (
|
|
233
|
+
collectionId,
|
|
234
|
+
documentData,
|
|
235
|
+
_options,
|
|
236
|
+
) => {
|
|
237
|
+
const tableName = this.#getTableName(collectionId);
|
|
238
|
+
const { id, ...data } = documentData;
|
|
239
|
+
try {
|
|
240
|
+
await this.#connection.execute(`INSERT INTO \`${tableName}\` (id, data) VALUES (?, ?)`, [
|
|
241
|
+
id,
|
|
242
|
+
JSON.stringify(data),
|
|
243
|
+
]);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
throw new DatabaseAdapterErrors.DocumentAlreadyExistsError(
|
|
246
|
+
collectionId,
|
|
247
|
+
documentData.id,
|
|
248
|
+
error,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
updateDocument: DatabaseAdapter["updateDocument"] = async (
|
|
254
|
+
collectionId,
|
|
255
|
+
documentId,
|
|
256
|
+
documentData,
|
|
257
|
+
) => {
|
|
258
|
+
const tableName = this.#getTableName(collectionId);
|
|
259
|
+
|
|
260
|
+
// Get existing document
|
|
261
|
+
const [rows] = await this.#connection.execute<StoryBookerDatabaseDocument[]>(
|
|
262
|
+
`SELECT data FROM \`${tableName}\` WHERE id = ? LIMIT 1`,
|
|
263
|
+
[documentId],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const [row] = rows;
|
|
267
|
+
if (!row) {
|
|
268
|
+
throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const existingData =
|
|
272
|
+
"data" in row && typeof row["data"] === "string" ? JSON.parse(row["data"]) : null;
|
|
273
|
+
const updatedData = { ...existingData, ...documentData };
|
|
274
|
+
|
|
275
|
+
await this.#connection.execute(`UPDATE \`${tableName}\` SET data = ? WHERE id = ?`, [
|
|
276
|
+
JSON.stringify(updatedData),
|
|
277
|
+
documentId,
|
|
278
|
+
]);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
deleteDocument: DatabaseAdapter["deleteDocument"] = async (
|
|
282
|
+
collectionId,
|
|
283
|
+
documentId,
|
|
284
|
+
_options,
|
|
285
|
+
) => {
|
|
286
|
+
const tableName = this.#getTableName(collectionId);
|
|
287
|
+
const [result] = await this.#connection.execute<{ affectedRows: number }>(
|
|
288
|
+
`DELETE FROM \`${tableName}\` WHERE id = ?`,
|
|
289
|
+
[documentId],
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (result.affectedRows === 0) {
|
|
293
|
+
throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// oxlint-disable no-explicit-any
|
|
2
|
+
// oxlint-disable no-unsafe-member-access
|
|
3
|
+
|
|
4
|
+
import type { RedisClientType } from "redis";
|
|
5
|
+
import {
|
|
6
|
+
DatabaseAdapterErrors,
|
|
7
|
+
type DatabaseAdapter,
|
|
8
|
+
type DatabaseAdapterOptions,
|
|
9
|
+
type DatabaseDocumentListOptions,
|
|
10
|
+
type StoryBookerDatabaseDocument,
|
|
11
|
+
} from "./_internal/database.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Redis database adapter for StoryBooker.
|
|
15
|
+
* Uses key prefixes to simulate collections since Redis is key-value store.
|
|
16
|
+
*
|
|
17
|
+
* Key structure: `collection:{collectionId}:doc:{documentId}`
|
|
18
|
+
* Collections list key: `collections`
|
|
19
|
+
*
|
|
20
|
+
* Compatible with all Redis services (Redis Cloud, AWS ElastiCache, Azure Cache, etc.)
|
|
21
|
+
*/
|
|
22
|
+
export class RedisDatabaseAdapter implements DatabaseAdapter {
|
|
23
|
+
#client: RedisClientType;
|
|
24
|
+
#keyPrefix: string;
|
|
25
|
+
|
|
26
|
+
constructor(client: RedisClientType, keyPrefix = "sbr") {
|
|
27
|
+
this.#client = client;
|
|
28
|
+
this.#keyPrefix = keyPrefix;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
metadata: DatabaseAdapter["metadata"] = { name: "Redis" };
|
|
32
|
+
|
|
33
|
+
init: DatabaseAdapter["init"] = async (_options) => {
|
|
34
|
+
// Ensure Redis connection is ready
|
|
35
|
+
if (!this.#client.isReady) {
|
|
36
|
+
try {
|
|
37
|
+
await this.#client.connect();
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new DatabaseAdapterErrors.DatabaseNotInitializedError(error);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Helper methods for key generation
|
|
45
|
+
// #getCollectionKey(collectionId: string): string {
|
|
46
|
+
// return `${this.#keyPrefix}:collection:${collectionId}`;
|
|
47
|
+
// }
|
|
48
|
+
|
|
49
|
+
#getDocumentKey(collectionId: string, documentId: string): string {
|
|
50
|
+
return `${this.#keyPrefix}:collection:${collectionId}:doc:${documentId}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#getCollectionsSetKey(): string {
|
|
54
|
+
return `${this.#keyPrefix}:collections`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
listCollections: DatabaseAdapter["listCollections"] = async (_options) => {
|
|
58
|
+
const collections = await this.#client.sMembers(this.#getCollectionsSetKey());
|
|
59
|
+
return collections;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
createCollection: DatabaseAdapter["createCollection"] = async (collectionId, _options) => {
|
|
63
|
+
try {
|
|
64
|
+
// Add collection to the set of collections
|
|
65
|
+
await this.#client.sAdd(this.#getCollectionsSetKey(), collectionId);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new DatabaseAdapterErrors.CollectionAlreadyExistsError(collectionId, error);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
hasCollection: DatabaseAdapter["hasCollection"] = async (collectionId, _options) => {
|
|
72
|
+
const exists = await this.#client.sIsMember(this.#getCollectionsSetKey(), collectionId);
|
|
73
|
+
return exists > 0;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
deleteCollection: DatabaseAdapter["deleteCollection"] = async (collectionId, _options) => {
|
|
77
|
+
try {
|
|
78
|
+
// Get all document keys for this collection
|
|
79
|
+
const pattern = this.#getDocumentKey(collectionId, "*");
|
|
80
|
+
const keys = await this.#client.keys(pattern);
|
|
81
|
+
|
|
82
|
+
// Delete all documents in the collection
|
|
83
|
+
if (keys.length > 0) {
|
|
84
|
+
await this.#client.del(keys);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Remove collection from the set
|
|
88
|
+
await this.#client.sRem(this.#getCollectionsSetKey(), collectionId);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId, error);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
listDocuments: DatabaseAdapter["listDocuments"] = async <
|
|
95
|
+
Document extends StoryBookerDatabaseDocument,
|
|
96
|
+
>(
|
|
97
|
+
collectionId: string,
|
|
98
|
+
listOptions: DatabaseDocumentListOptions<Document>,
|
|
99
|
+
_options: DatabaseAdapterOptions,
|
|
100
|
+
): Promise<Document[]> => {
|
|
101
|
+
const pattern = this.#getDocumentKey(collectionId, "*");
|
|
102
|
+
const keys = await this.#client.keys(pattern);
|
|
103
|
+
|
|
104
|
+
if (keys.length === 0) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Get all documents
|
|
109
|
+
const values = await this.#client.mGet(keys);
|
|
110
|
+
const documents: Document[] = values
|
|
111
|
+
.filter((value): value is string => value !== null)
|
|
112
|
+
.map((value) => JSON.parse(value) as Document);
|
|
113
|
+
|
|
114
|
+
// Apply filtering
|
|
115
|
+
let filteredDocs = documents;
|
|
116
|
+
const filterFn =
|
|
117
|
+
listOptions?.filter && typeof listOptions.filter === "function"
|
|
118
|
+
? listOptions.filter
|
|
119
|
+
: undefined;
|
|
120
|
+
if (filterFn) {
|
|
121
|
+
filteredDocs = documents.filter((doc) => filterFn(doc));
|
|
122
|
+
}
|
|
123
|
+
// String filters could be implemented as key pattern matching if needed
|
|
124
|
+
|
|
125
|
+
// Apply sorting
|
|
126
|
+
if (listOptions?.sort) {
|
|
127
|
+
if (typeof listOptions.sort === "function") {
|
|
128
|
+
filteredDocs.sort(listOptions.sort);
|
|
129
|
+
} else if (listOptions.sort === "latest") {
|
|
130
|
+
// Assume documents have a createdAt or similar timestamp field
|
|
131
|
+
filteredDocs.sort((docA, docB) => {
|
|
132
|
+
const aTime = (docA as any).createdAt ?? (docA as any).timestamp ?? 0;
|
|
133
|
+
const bTime = (docB as any).createdAt ?? (docB as any).timestamp ?? 0;
|
|
134
|
+
return bTime - aTime; // Descending order (latest first)
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Apply limit
|
|
140
|
+
if (listOptions?.limit && listOptions.limit > 0) {
|
|
141
|
+
filteredDocs = filteredDocs.slice(0, listOptions.limit);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// // Apply select (field projection)
|
|
145
|
+
// if (listOptions?.select && listOptions.select.length > 0) {
|
|
146
|
+
// filteredDocs = filteredDocs.map((doc) => {
|
|
147
|
+
// const projected = { id: doc.id } as Document;
|
|
148
|
+
// // oxlint-disable-next-line no-non-null-assertion
|
|
149
|
+
// for (const field of listOptions.select!) {
|
|
150
|
+
// if (field in doc) {
|
|
151
|
+
// projected[field] = (doc as any)[field];
|
|
152
|
+
// }
|
|
153
|
+
// }
|
|
154
|
+
// return projected;
|
|
155
|
+
// });
|
|
156
|
+
// }
|
|
157
|
+
|
|
158
|
+
return filteredDocs;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
getDocument: DatabaseAdapter["getDocument"] = async <
|
|
162
|
+
Document extends StoryBookerDatabaseDocument,
|
|
163
|
+
>(
|
|
164
|
+
collectionId: string,
|
|
165
|
+
documentId: string,
|
|
166
|
+
_options: DatabaseAdapterOptions,
|
|
167
|
+
): Promise<Document> => {
|
|
168
|
+
const key = this.#getDocumentKey(collectionId, documentId);
|
|
169
|
+
const value = await this.#client.get(key);
|
|
170
|
+
|
|
171
|
+
if (!value) {
|
|
172
|
+
throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const document: Document = JSON.parse(value);
|
|
176
|
+
return document;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
hasDocument: DatabaseAdapter["hasDocument"] = async (collectionId, documentId, _options) => {
|
|
180
|
+
const key = this.#getDocumentKey(collectionId, documentId);
|
|
181
|
+
const exists = await this.#client.exists(key);
|
|
182
|
+
return exists === 1;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
createDocument: DatabaseAdapter["createDocument"] = async (
|
|
186
|
+
collectionId,
|
|
187
|
+
documentData,
|
|
188
|
+
_options,
|
|
189
|
+
) => {
|
|
190
|
+
// Ensure collection exists
|
|
191
|
+
try {
|
|
192
|
+
await this.#client.sAdd(this.#getCollectionsSetKey(), collectionId);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId, error);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const key = this.#getDocumentKey(collectionId, documentData.id);
|
|
199
|
+
const value = JSON.stringify(documentData);
|
|
200
|
+
await this.#client.set(key, value);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
throw new DatabaseAdapterErrors.DocumentAlreadyExistsError(
|
|
203
|
+
collectionId,
|
|
204
|
+
documentData.id,
|
|
205
|
+
error,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
updateDocument: DatabaseAdapter["updateDocument"] = async (
|
|
211
|
+
collectionId,
|
|
212
|
+
documentId,
|
|
213
|
+
documentData,
|
|
214
|
+
) => {
|
|
215
|
+
const key = this.#getDocumentKey(collectionId, documentId);
|
|
216
|
+
|
|
217
|
+
// Get existing document
|
|
218
|
+
const existingValue = await this.#client.get(key);
|
|
219
|
+
if (!existingValue) {
|
|
220
|
+
throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const existingDoc = JSON.parse(existingValue);
|
|
224
|
+
const updatedDoc = { ...existingDoc, ...documentData };
|
|
225
|
+
|
|
226
|
+
const value = JSON.stringify(updatedDoc);
|
|
227
|
+
await this.#client.set(key, value);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
deleteDocument: DatabaseAdapter["deleteDocument"] = async (
|
|
231
|
+
collectionId,
|
|
232
|
+
documentId,
|
|
233
|
+
_options,
|
|
234
|
+
) => {
|
|
235
|
+
const key = this.#getDocumentKey(collectionId, documentId);
|
|
236
|
+
const deleted = await this.#client.del(key);
|
|
237
|
+
|
|
238
|
+
if (deleted === 0) {
|
|
239
|
+
throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|