@divizend/scratch-core 1.0.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 (61) hide show
  1. package/basic/demo.ts +11 -0
  2. package/basic/index.ts +490 -0
  3. package/core/Auth.ts +63 -0
  4. package/core/Currency.ts +16 -0
  5. package/core/Env.ts +186 -0
  6. package/core/Fragment.ts +43 -0
  7. package/core/FragmentServingMode.ts +37 -0
  8. package/core/JsonSchemaValidator.ts +173 -0
  9. package/core/ProjectRoot.ts +76 -0
  10. package/core/Scratch.ts +44 -0
  11. package/core/URI.ts +203 -0
  12. package/core/Universe.ts +406 -0
  13. package/core/index.ts +27 -0
  14. package/gsuite/core/GSuite.ts +237 -0
  15. package/gsuite/core/GSuiteAdmin.ts +81 -0
  16. package/gsuite/core/GSuiteOrgConfig.ts +47 -0
  17. package/gsuite/core/GSuiteUser.ts +115 -0
  18. package/gsuite/core/index.ts +21 -0
  19. package/gsuite/documents/Document.ts +173 -0
  20. package/gsuite/documents/Documents.ts +52 -0
  21. package/gsuite/documents/index.ts +19 -0
  22. package/gsuite/drive/Drive.ts +118 -0
  23. package/gsuite/drive/DriveFile.ts +147 -0
  24. package/gsuite/drive/index.ts +19 -0
  25. package/gsuite/gmail/Gmail.ts +430 -0
  26. package/gsuite/gmail/GmailLabel.ts +55 -0
  27. package/gsuite/gmail/GmailMessage.ts +428 -0
  28. package/gsuite/gmail/GmailMessagePart.ts +298 -0
  29. package/gsuite/gmail/GmailThread.ts +97 -0
  30. package/gsuite/gmail/index.ts +5 -0
  31. package/gsuite/gmail/utils.ts +184 -0
  32. package/gsuite/index.ts +28 -0
  33. package/gsuite/spreadsheets/CellValue.ts +71 -0
  34. package/gsuite/spreadsheets/Sheet.ts +128 -0
  35. package/gsuite/spreadsheets/SheetValues.ts +12 -0
  36. package/gsuite/spreadsheets/Spreadsheet.ts +76 -0
  37. package/gsuite/spreadsheets/Spreadsheets.ts +52 -0
  38. package/gsuite/spreadsheets/index.ts +25 -0
  39. package/gsuite/spreadsheets/utils.ts +52 -0
  40. package/gsuite/utils.ts +104 -0
  41. package/http-server/HttpServer.ts +110 -0
  42. package/http-server/NativeHttpServer.ts +1084 -0
  43. package/http-server/index.ts +3 -0
  44. package/http-server/middlewares/01-cors.ts +33 -0
  45. package/http-server/middlewares/02-static.ts +67 -0
  46. package/http-server/middlewares/03-request-logger.ts +159 -0
  47. package/http-server/middlewares/04-body-parser.ts +54 -0
  48. package/http-server/middlewares/05-no-cache.ts +23 -0
  49. package/http-server/middlewares/06-response-handler.ts +39 -0
  50. package/http-server/middlewares/handler-wrapper.ts +250 -0
  51. package/http-server/middlewares/index.ts +37 -0
  52. package/http-server/middlewares/types.ts +27 -0
  53. package/index.ts +24 -0
  54. package/package.json +37 -0
  55. package/queue/EmailQueue.ts +228 -0
  56. package/queue/RateLimiter.ts +54 -0
  57. package/queue/index.ts +2 -0
  58. package/resend/Resend.ts +190 -0
  59. package/resend/index.ts +11 -0
  60. package/s2/S2.ts +335 -0
  61. package/s2/index.ts +11 -0
package/s2/S2.ts ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * S2 - Streamstore Service Adapter
3
+ *
4
+ * The S2 class provides an adapter for the S2 streamstore service,
5
+ * abstracting the stream operations and configuration.
6
+ * All data is automatically wrapped in CloudEvents format when appending
7
+ * and unwrapped when reading, so blocks only work with the payload.
8
+ *
9
+ * @class S2
10
+ * @version 1.0.0
11
+ */
12
+
13
+ import { S2 as S2Client, AppendRecord } from "@s2-dev/streamstore";
14
+ import { CloudEvent } from "cloudevents";
15
+ import { envOr, env, envOrDefault } from "../index";
16
+
17
+ export interface S2ReadResult {
18
+ records: any[];
19
+ }
20
+
21
+ export class S2 {
22
+ private client: S2Client;
23
+
24
+ private constructor(accessToken: string) {
25
+ this.client = new S2Client({ accessToken });
26
+ }
27
+
28
+ static construct(accessToken?: string): S2 {
29
+ const token = envOr(
30
+ accessToken,
31
+ "S2_ACCESS_TOKEN",
32
+ "S2_ACCESS_TOKEN environment variable is not set. Please configure it to use streamstore features."
33
+ );
34
+ return new S2(token);
35
+ }
36
+
37
+ static getBasin(): string {
38
+ return env("S2_BASIN", {
39
+ errorMessage: "S2_BASIN environment variable is required but not set.",
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Generates event type from HOSTED_AT and stream name
45
+ * Reverses the domain (e.g., "scratch.divizend.ai" -> "ai.divizend.scratch")
46
+ * and appends the stream name
47
+ */
48
+ private static getEventType(streamName: string): string {
49
+ const hostedAt = envOrDefault(
50
+ undefined,
51
+ "HOSTED_AT",
52
+ "scratch.divizend.ai"
53
+ );
54
+ // Remove protocol if present
55
+ const domain = hostedAt.replace(/^https?:\/\//, "");
56
+ // Reverse the domain parts
57
+ const reversed = domain.split(".").reverse().join(".");
58
+ // Append stream name
59
+ return `${reversed}.${streamName}`;
60
+ }
61
+
62
+ /**
63
+ * Appends data to a stream
64
+ * Automatically wraps the data in a CloudEvent with generated headers
65
+ */
66
+ async appendToStream(
67
+ basinName: string,
68
+ streamName: string,
69
+ data: any
70
+ ): Promise<void> {
71
+ const basin = this.client.basin(basinName);
72
+ const stream = basin.stream(streamName);
73
+
74
+ // Generate event type from HOSTED_AT and stream name
75
+ const eventType = S2.getEventType(streamName);
76
+
77
+ // Create a CloudEvent with the payload
78
+ const event = new CloudEvent({
79
+ source: `ai.divizend.scratch/${streamName}`,
80
+ type: eventType,
81
+ data: data, // The actual payload from the block
82
+ });
83
+
84
+ // Serialize the CloudEvent to JSON
85
+ const eventBody = JSON.stringify(event);
86
+
87
+ await stream.append([AppendRecord.make(eventBody)]);
88
+ }
89
+
90
+ /**
91
+ * Reads records from a stream starting from the beginning (raw CloudEvents)
92
+ * Returns the full CloudEvent objects, not just the payload
93
+ */
94
+ async readFromStreamRaw(
95
+ basinName: string,
96
+ streamName: string,
97
+ limit: number
98
+ ): Promise<S2ReadResult> {
99
+ const basin = this.client.basin(basinName);
100
+ const stream = basin.stream(streamName);
101
+ try {
102
+ const readBatch = await stream.read({ seq_num: 0, count: limit });
103
+ const records = ((readBatch as any).records || []).map((record: any) =>
104
+ this.parseRecordRaw(record)
105
+ );
106
+ return { records };
107
+ } catch (error: any) {
108
+ // Check if it's a stream not found error - re-throw so it can be handled as 404
109
+ const errorMessage =
110
+ error?.message || error?.data$?.message || String(error);
111
+ const status =
112
+ error?.status || error?.statusCode || error?.response?.status;
113
+ const code = error?.code || error?.data$?.code;
114
+
115
+ if (
116
+ status === 404 ||
117
+ code === "stream_not_found" ||
118
+ code === "not_found" ||
119
+ errorMessage.includes("not found") ||
120
+ errorMessage.includes("Stream not found")
121
+ ) {
122
+ // Re-throw stream not found errors so they can be handled as 404
123
+ throw error;
124
+ }
125
+
126
+ // Re-throw other errors
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Checks the tail (latest records) of a stream (raw CloudEvents)
133
+ * Returns the full CloudEvent objects, not just the payload
134
+ */
135
+ async checkStreamTailRaw(
136
+ basinName: string,
137
+ streamName: string,
138
+ limit: number
139
+ ): Promise<S2ReadResult> {
140
+ const basin = this.client.basin(basinName);
141
+ const stream = basin.stream(streamName);
142
+
143
+ try {
144
+ // Get tail position (i.e. the _next_ sequence number)
145
+ const tailResponse = await stream.checkTail();
146
+ const tail = tailResponse.tail;
147
+
148
+ if (!tail || tail.seq_num === undefined || tail.seq_num < 0) {
149
+ return { records: [] };
150
+ }
151
+
152
+ const startSeq = Math.max(0, tail.seq_num - limit);
153
+ const count = Math.min(tail.seq_num, limit);
154
+
155
+ const readResult = await stream.read({
156
+ seq_num: startSeq,
157
+ count: count,
158
+ });
159
+
160
+ const records = ((readResult as any).records || []).map((record: any) =>
161
+ this.parseRecordRaw(record)
162
+ );
163
+
164
+ return { records };
165
+ } catch (error: any) {
166
+ // Check if it's a stream not found error - re-throw so it can be handled as 404
167
+ const errorMessage =
168
+ error?.message || error?.data$?.message || String(error);
169
+ const status =
170
+ error?.status || error?.statusCode || error?.response?.status;
171
+ const code = error?.code || error?.data$?.code;
172
+
173
+ if (
174
+ status === 404 ||
175
+ code === "stream_not_found" ||
176
+ code === "not_found" ||
177
+ errorMessage.includes("not found") ||
178
+ errorMessage.includes("Stream not found")
179
+ ) {
180
+ // Re-throw stream not found errors so they can be handled as 404
181
+ throw error;
182
+ }
183
+
184
+ // Re-throw other errors
185
+ throw error;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Reads records from a stream starting from the beginning
191
+ * Returns only the payload (data field) from each CloudEvent
192
+ */
193
+ async readFromStream(
194
+ basinName: string,
195
+ streamName: string,
196
+ limit: number
197
+ ): Promise<S2ReadResult> {
198
+ const rawResult = await this.readFromStreamRaw(
199
+ basinName,
200
+ streamName,
201
+ limit
202
+ );
203
+ // Extract only the data field from each CloudEvent
204
+ const records = rawResult.records.map((event: any) => {
205
+ if (event && typeof event === "object" && event.data !== undefined) {
206
+ return event.data;
207
+ }
208
+ return event;
209
+ });
210
+ return { records };
211
+ }
212
+
213
+ /**
214
+ * Checks the tail (latest records) of a stream
215
+ * Returns only the payload (data field) from each CloudEvent
216
+ */
217
+ async checkStreamTail(
218
+ basinName: string,
219
+ streamName: string,
220
+ limit: number
221
+ ): Promise<S2ReadResult> {
222
+ const rawResult = await this.checkStreamTailRaw(
223
+ basinName,
224
+ streamName,
225
+ limit
226
+ );
227
+ // Extract only the data field from each CloudEvent
228
+ const records = rawResult.records.map((event: any) => {
229
+ if (event && typeof event === "object" && event.data !== undefined) {
230
+ return event.data;
231
+ }
232
+ return event;
233
+ });
234
+ return { records };
235
+ }
236
+
237
+ /**
238
+ * Creates a stream in the specified basin
239
+ */
240
+ async createStream(
241
+ basinName: string,
242
+ streamName: string
243
+ ): Promise<{ created: boolean; message: string }> {
244
+ // Disallow creating a stream named "extension"
245
+ if (streamName === "extension") {
246
+ throw new Error('Stream name "extension" is not allowed');
247
+ }
248
+
249
+ const basin = this.client.basin(basinName);
250
+
251
+ try {
252
+ await (basin as any).streams.create({
253
+ stream: streamName,
254
+ });
255
+ return {
256
+ created: true,
257
+ message: `Stream ${streamName} created successfully in basin ${basinName}`,
258
+ };
259
+ } catch (error: any) {
260
+ const status =
261
+ error?.status || error?.statusCode || error?.response?.status;
262
+ const code = error?.code || error?.data$?.code;
263
+ const message = error?.message || error?.data$?.message || String(error);
264
+
265
+ if (
266
+ status === 409 ||
267
+ code === "stream_exists" ||
268
+ code === "conflict" ||
269
+ message.includes("already exists") ||
270
+ message.includes("conflict")
271
+ ) {
272
+ return {
273
+ created: false,
274
+ message: `Stream ${streamName} already exists in basin ${basinName}`,
275
+ };
276
+ }
277
+
278
+ throw error;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Checks the health of the S2 service
284
+ */
285
+ async getHealth(): Promise<{
286
+ status: string;
287
+ message: string;
288
+ connected: boolean;
289
+ }> {
290
+ try {
291
+ if (!this.client) {
292
+ throw new Error("S2 client not initialized");
293
+ }
294
+ const testBasin = this.client.basin("_health_check");
295
+ if (!testBasin) {
296
+ throw new Error("Failed to access S2 basin");
297
+ }
298
+ return {
299
+ status: "ok",
300
+ message: "S2 streamstore connection active",
301
+ connected: true,
302
+ };
303
+ } catch (error) {
304
+ return {
305
+ status: "error",
306
+ message: error instanceof Error ? error.message : "Unknown error",
307
+ connected: false,
308
+ };
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Parses a record to extract the raw CloudEvent
314
+ * Returns the full CloudEvent object including all metadata
315
+ */
316
+ private parseRecordRaw(record: any): any {
317
+ if (!record) return undefined;
318
+
319
+ // Get the body (which should contain the CloudEvent JSON)
320
+ const body = record.body;
321
+ if (!body) return undefined;
322
+
323
+ // Parse the CloudEvent JSON
324
+ if (typeof body === "string") {
325
+ try {
326
+ return JSON.parse(body);
327
+ } catch {
328
+ // If it's not JSON, return as-is
329
+ return body;
330
+ }
331
+ }
332
+
333
+ return body;
334
+ }
335
+ }
package/s2/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * S2 Module - Streamstore Service Adapter
3
+ *
4
+ * This module provides an adapter for the S2 streamstore service.
5
+ *
6
+ * @module S2
7
+ * @version 1.0.0
8
+ * @author Divizend GmbH
9
+ */
10
+
11
+ export * from "./S2";