@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.
- package/basic/demo.ts +11 -0
- package/basic/index.ts +490 -0
- package/core/Auth.ts +63 -0
- package/core/Currency.ts +16 -0
- package/core/Env.ts +186 -0
- package/core/Fragment.ts +43 -0
- package/core/FragmentServingMode.ts +37 -0
- package/core/JsonSchemaValidator.ts +173 -0
- package/core/ProjectRoot.ts +76 -0
- package/core/Scratch.ts +44 -0
- package/core/URI.ts +203 -0
- package/core/Universe.ts +406 -0
- package/core/index.ts +27 -0
- package/gsuite/core/GSuite.ts +237 -0
- package/gsuite/core/GSuiteAdmin.ts +81 -0
- package/gsuite/core/GSuiteOrgConfig.ts +47 -0
- package/gsuite/core/GSuiteUser.ts +115 -0
- package/gsuite/core/index.ts +21 -0
- package/gsuite/documents/Document.ts +173 -0
- package/gsuite/documents/Documents.ts +52 -0
- package/gsuite/documents/index.ts +19 -0
- package/gsuite/drive/Drive.ts +118 -0
- package/gsuite/drive/DriveFile.ts +147 -0
- package/gsuite/drive/index.ts +19 -0
- package/gsuite/gmail/Gmail.ts +430 -0
- package/gsuite/gmail/GmailLabel.ts +55 -0
- package/gsuite/gmail/GmailMessage.ts +428 -0
- package/gsuite/gmail/GmailMessagePart.ts +298 -0
- package/gsuite/gmail/GmailThread.ts +97 -0
- package/gsuite/gmail/index.ts +5 -0
- package/gsuite/gmail/utils.ts +184 -0
- package/gsuite/index.ts +28 -0
- package/gsuite/spreadsheets/CellValue.ts +71 -0
- package/gsuite/spreadsheets/Sheet.ts +128 -0
- package/gsuite/spreadsheets/SheetValues.ts +12 -0
- package/gsuite/spreadsheets/Spreadsheet.ts +76 -0
- package/gsuite/spreadsheets/Spreadsheets.ts +52 -0
- package/gsuite/spreadsheets/index.ts +25 -0
- package/gsuite/spreadsheets/utils.ts +52 -0
- package/gsuite/utils.ts +104 -0
- package/http-server/HttpServer.ts +110 -0
- package/http-server/NativeHttpServer.ts +1084 -0
- package/http-server/index.ts +3 -0
- package/http-server/middlewares/01-cors.ts +33 -0
- package/http-server/middlewares/02-static.ts +67 -0
- package/http-server/middlewares/03-request-logger.ts +159 -0
- package/http-server/middlewares/04-body-parser.ts +54 -0
- package/http-server/middlewares/05-no-cache.ts +23 -0
- package/http-server/middlewares/06-response-handler.ts +39 -0
- package/http-server/middlewares/handler-wrapper.ts +250 -0
- package/http-server/middlewares/index.ts +37 -0
- package/http-server/middlewares/types.ts +27 -0
- package/index.ts +24 -0
- package/package.json +37 -0
- package/queue/EmailQueue.ts +228 -0
- package/queue/RateLimiter.ts +54 -0
- package/queue/index.ts +2 -0
- package/resend/Resend.ts +190 -0
- package/resend/index.ts +11 -0
- package/s2/S2.ts +335 -0
- 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
|
+
}
|