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.
Files changed (215) hide show
  1. package/README.md +40 -18
  2. package/dist/adapters/_internal/queue.d.mts +127 -0
  3. package/dist/aws-dynamodb.d.mts +22 -0
  4. package/dist/aws-dynamodb.mjs +118 -0
  5. package/dist/aws-dynamodb.mjs.map +1 -0
  6. package/dist/aws-s3.d.mts +20 -0
  7. package/dist/aws-s3.mjs +96 -0
  8. package/dist/aws-s3.mjs.map +1 -0
  9. package/dist/azure-blob-storage.d.mts +20 -0
  10. package/dist/azure-blob-storage.mjs +126 -0
  11. package/dist/azure-blob-storage.mjs.map +1 -0
  12. package/dist/azure-cosmos-db.d.mts +23 -0
  13. package/dist/azure-cosmos-db.mjs +87 -0
  14. package/dist/azure-cosmos-db.mjs.map +1 -0
  15. package/dist/azure-data-tables.d.mts +23 -0
  16. package/dist/azure-data-tables.mjs +127 -0
  17. package/dist/azure-data-tables.mjs.map +1 -0
  18. package/dist/azure-easy-auth.d.mts +50 -0
  19. package/dist/azure-easy-auth.mjs +88 -0
  20. package/dist/azure-easy-auth.mjs.map +1 -0
  21. package/dist/azure-functions.d.mts +62 -0
  22. package/dist/azure-functions.mjs +147 -0
  23. package/dist/azure-functions.mjs.map +1 -0
  24. package/dist/fs.d.mts +37 -0
  25. package/dist/fs.mjs +240 -0
  26. package/dist/fs.mjs.map +1 -0
  27. package/dist/gcp-big-table.d.mts +23 -0
  28. package/dist/gcp-big-table.mjs +92 -0
  29. package/dist/gcp-big-table.mjs.map +1 -0
  30. package/dist/gcp-firestore.d.mts +22 -0
  31. package/dist/gcp-firestore.mjs +87 -0
  32. package/dist/gcp-firestore.mjs.map +1 -0
  33. package/dist/gcp-storage.d.mts +20 -0
  34. package/dist/gcp-storage.mjs +96 -0
  35. package/dist/gcp-storage.mjs.map +1 -0
  36. package/dist/handlers/handle-process-zip.mjs +90 -0
  37. package/dist/handlers/handle-process-zip.mjs.map +1 -0
  38. package/dist/handlers/handle-purge.d.mts +12 -0
  39. package/dist/handlers/handle-purge.mjs +36 -0
  40. package/dist/handlers/handle-purge.mjs.map +1 -0
  41. package/dist/handlers/handle-serve-storybook.mjs +94 -0
  42. package/dist/handlers/handle-serve-storybook.mjs.map +1 -0
  43. package/dist/index.d.mts +28 -0
  44. package/dist/index.mjs +62 -0
  45. package/dist/index.mjs.map +1 -0
  46. package/dist/models/builds-model.mjs +248 -0
  47. package/dist/models/builds-model.mjs.map +1 -0
  48. package/dist/models/builds-schema.d.mts +171 -0
  49. package/dist/models/builds-schema.mjs +67 -0
  50. package/dist/models/builds-schema.mjs.map +1 -0
  51. package/dist/models/projects-model.mjs +122 -0
  52. package/dist/models/projects-model.mjs.map +1 -0
  53. package/dist/models/projects-schema.d.mts +70 -0
  54. package/dist/models/projects-schema.mjs +37 -0
  55. package/dist/models/projects-schema.mjs.map +1 -0
  56. package/dist/models/tags-model.mjs +110 -0
  57. package/dist/models/tags-model.mjs.map +1 -0
  58. package/dist/models/tags-schema.d.mts +76 -0
  59. package/dist/models/tags-schema.mjs +34 -0
  60. package/dist/models/tags-schema.mjs.map +1 -0
  61. package/dist/models/~model.mjs +43 -0
  62. package/dist/models/~model.mjs.map +1 -0
  63. package/dist/models/~shared-schema.d.mts +1 -0
  64. package/dist/models/~shared-schema.mjs +20 -0
  65. package/dist/models/~shared-schema.mjs.map +1 -0
  66. package/dist/mysql.d.mts +39 -0
  67. package/dist/mysql.mjs +151 -0
  68. package/dist/mysql.mjs.map +1 -0
  69. package/dist/redis.d.mts +33 -0
  70. package/dist/redis.mjs +118 -0
  71. package/dist/redis.mjs.map +1 -0
  72. package/dist/routers/account-router.mjs +91 -0
  73. package/dist/routers/account-router.mjs.map +1 -0
  74. package/dist/routers/builds-router.mjs +347 -0
  75. package/dist/routers/builds-router.mjs.map +1 -0
  76. package/dist/routers/projects-router.mjs +236 -0
  77. package/dist/routers/projects-router.mjs.map +1 -0
  78. package/dist/routers/root-router.mjs +108 -0
  79. package/dist/routers/root-router.mjs.map +1 -0
  80. package/dist/routers/tags-router.mjs +269 -0
  81. package/dist/routers/tags-router.mjs.map +1 -0
  82. package/dist/routers/tasks-router.mjs +71 -0
  83. package/dist/routers/tasks-router.mjs.map +1 -0
  84. package/dist/urls.d.mts +47 -0
  85. package/dist/urls.mjs +208 -0
  86. package/dist/urls.mjs.map +1 -0
  87. package/dist/utils/adapter-utils.d.mts +14 -0
  88. package/dist/utils/adapter-utils.mjs +14 -0
  89. package/dist/utils/adapter-utils.mjs.map +1 -0
  90. package/dist/utils/auth.mjs +25 -0
  91. package/dist/utils/auth.mjs.map +1 -0
  92. package/dist/utils/error.d.mts +21 -0
  93. package/dist/utils/error.mjs +109 -0
  94. package/dist/utils/error.mjs.map +1 -0
  95. package/dist/utils/file-utils.mjs +16 -0
  96. package/dist/utils/file-utils.mjs.map +1 -0
  97. package/dist/utils/openapi-utils.mjs +45 -0
  98. package/dist/utils/openapi-utils.mjs.map +1 -0
  99. package/dist/utils/request.mjs +35 -0
  100. package/dist/utils/request.mjs.map +1 -0
  101. package/dist/utils/response.mjs +24 -0
  102. package/dist/utils/response.mjs.map +1 -0
  103. package/dist/utils/store.mjs +54 -0
  104. package/dist/utils/store.mjs.map +1 -0
  105. package/dist/utils/ui-utils.mjs +38 -0
  106. package/dist/utils/ui-utils.mjs.map +1 -0
  107. package/dist/utils/url-utils.d.mts +10 -0
  108. package/dist/utils/url-utils.mjs +54 -0
  109. package/dist/utils/url-utils.mjs.map +1 -0
  110. package/dist/~internal/adapter/auth.d.mts +123 -0
  111. package/dist/~internal/adapter/auth.mjs +20 -0
  112. package/dist/~internal/adapter/auth.mjs.map +1 -0
  113. package/dist/~internal/adapter/database.d.mts +240 -0
  114. package/dist/~internal/adapter/database.mjs +63 -0
  115. package/dist/~internal/adapter/database.mjs.map +1 -0
  116. package/dist/~internal/adapter/logger.d.mts +34 -0
  117. package/dist/~internal/adapter/logger.mjs +13 -0
  118. package/dist/~internal/adapter/logger.mjs.map +1 -0
  119. package/dist/~internal/adapter/storage.d.mts +208 -0
  120. package/dist/~internal/adapter/storage.mjs +63 -0
  121. package/dist/~internal/adapter/storage.mjs.map +1 -0
  122. package/dist/~internal/adapter/ui.d.mts +109 -0
  123. package/dist/~internal/adapter/ui.mjs +1 -0
  124. package/dist/~internal/adapter.d.mts +8 -0
  125. package/dist/~internal/adapter.mjs +6 -0
  126. package/dist/~internal/constants.d.mts +24 -0
  127. package/dist/~internal/constants.mjs +32 -0
  128. package/dist/~internal/constants.mjs.map +1 -0
  129. package/dist/~internal/mimes.d.mts +449 -0
  130. package/dist/~internal/mimes.mjs +454 -0
  131. package/dist/~internal/mimes.mjs.map +1 -0
  132. package/dist/~internal/router.d.mts +1651 -0
  133. package/dist/~internal/router.mjs +39 -0
  134. package/dist/~internal/router.mjs.map +1 -0
  135. package/dist/~internal/types.d.mts +77 -0
  136. package/dist/~internal/types.mjs +1 -0
  137. package/dist/~internal/utils.d.mts +4 -0
  138. package/dist/~internal/utils.mjs +5 -0
  139. package/openapi.json +3162 -0
  140. package/package.json +148 -27
  141. package/src/adapters/_internal/auth.ts +135 -0
  142. package/src/adapters/_internal/database.ts +241 -0
  143. package/src/adapters/_internal/index.ts +8 -0
  144. package/src/adapters/_internal/logger.ts +41 -0
  145. package/src/adapters/_internal/queue.ts +151 -0
  146. package/src/adapters/_internal/storage.ts +197 -0
  147. package/src/adapters/_internal/ui.ts +103 -0
  148. package/src/adapters/aws-dynamodb.ts +201 -0
  149. package/src/adapters/aws-s3.ts +160 -0
  150. package/src/adapters/azure-blob-storage.ts +223 -0
  151. package/src/adapters/azure-cosmos-db.ts +158 -0
  152. package/src/adapters/azure-data-tables.ts +223 -0
  153. package/src/adapters/azure-easy-auth.ts +174 -0
  154. package/src/adapters/azure-functions.ts +242 -0
  155. package/src/adapters/fs.ts +398 -0
  156. package/src/adapters/gcp-big-table.ts +157 -0
  157. package/src/adapters/gcp-firestore.ts +146 -0
  158. package/src/adapters/gcp-storage.ts +141 -0
  159. package/src/adapters/mysql.ts +296 -0
  160. package/src/adapters/redis.ts +242 -0
  161. package/src/handlers/handle-process-zip.ts +117 -0
  162. package/src/handlers/handle-purge.ts +65 -0
  163. package/src/handlers/handle-serve-storybook.ts +101 -0
  164. package/src/index.ts +81 -16
  165. package/src/mocks/mock-auth-service.ts +51 -0
  166. package/src/mocks/mock-store.ts +26 -0
  167. package/src/models/builds-model.ts +373 -0
  168. package/src/models/builds-schema.ts +84 -0
  169. package/src/models/projects-model.ts +177 -0
  170. package/src/models/projects-schema.ts +69 -0
  171. package/src/models/tags-model.ts +138 -0
  172. package/src/models/tags-schema.ts +45 -0
  173. package/src/models/~model.ts +79 -0
  174. package/src/models/~shared-schema.ts +14 -0
  175. package/src/routers/_app-router.ts +57 -0
  176. package/src/routers/account-router.ts +136 -0
  177. package/src/routers/builds-router.ts +464 -0
  178. package/src/routers/projects-router.ts +309 -0
  179. package/src/routers/root-router.ts +127 -0
  180. package/src/routers/tags-router.ts +339 -0
  181. package/src/routers/tasks-router.ts +75 -0
  182. package/src/types.ts +107 -0
  183. package/src/urls.ts +327 -0
  184. package/src/utils/adapter-utils.ts +26 -0
  185. package/src/utils/auth.test.ts +71 -0
  186. package/src/utils/auth.ts +39 -0
  187. package/src/utils/constants.ts +31 -0
  188. package/src/utils/date-utils.ts +10 -0
  189. package/src/utils/error.test.ts +86 -0
  190. package/src/utils/error.ts +140 -0
  191. package/src/utils/file-utils.test.ts +65 -0
  192. package/src/utils/file-utils.ts +43 -0
  193. package/src/utils/index.ts +3 -0
  194. package/src/utils/mime-utils.ts +457 -0
  195. package/src/utils/openapi-utils.ts +49 -0
  196. package/src/utils/request.ts +97 -0
  197. package/src/utils/response.ts +20 -0
  198. package/src/utils/store.ts +85 -0
  199. package/src/utils/story-utils.ts +42 -0
  200. package/src/utils/text-utils.ts +10 -0
  201. package/src/utils/ui-utils.ts +57 -0
  202. package/src/utils/url-utils.ts +113 -0
  203. package/dist/index.js +0 -554
  204. package/src/commands/create.ts +0 -263
  205. package/src/commands/purge.ts +0 -70
  206. package/src/commands/test.ts +0 -42
  207. package/src/service-schema.d.ts +0 -2023
  208. package/src/utils/auth-utils.ts +0 -31
  209. package/src/utils/pkg-utils.ts +0 -37
  210. package/src/utils/sb-build.ts +0 -55
  211. package/src/utils/sb-test.ts +0 -115
  212. package/src/utils/schema-utils.ts +0 -123
  213. package/src/utils/stream-utils.ts +0 -72
  214. package/src/utils/types.ts +0 -4
  215. 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
+ }