@mastra/upstash 0.0.0-workflow-deno-20250616130925 → 0.0.0-working-memory-per-user-20250620161509

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mastra/upstash",
3
- "version": "0.0.0-workflow-deno-20250616130925",
3
+ "version": "0.0.0-working-memory-per-user-20250620161509",
4
4
  "description": "Upstash provider for Mastra - includes both vector and db storage capabilities",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,12 +31,12 @@
31
31
  "tsup": "^8.5.0",
32
32
  "typescript": "^5.8.3",
33
33
  "vitest": "^3.2.3",
34
- "@internal/lint": "0.0.0-workflow-deno-20250616130925",
35
- "@internal/storage-test-utils": "0.0.0-workflow-deno-20250616130925",
36
- "@mastra/core": "0.0.0-workflow-deno-20250616130925"
34
+ "@internal/storage-test-utils": "0.0.9",
35
+ "@mastra/core": "0.0.0-working-memory-per-user-20250620161509",
36
+ "@internal/lint": "0.0.0-working-memory-per-user-20250620161509"
37
37
  },
38
38
  "peerDependencies": {
39
- "@mastra/core": ">=0.10.4-0 <0.11.0"
39
+ "@mastra/core": "0.0.0-working-memory-per-user-20250620161509"
40
40
  },
41
41
  "scripts": {
42
42
  "pretest": "docker compose up -d",
@@ -1,9 +1,12 @@
1
+ import { MessageList } from '@mastra/core/agent';
2
+ import type { MastraMessageContentV2, MastraMessageV2 } from '@mastra/core/agent';
1
3
  import type { MetricResult, TestInfo } from '@mastra/core/eval';
2
- import type { StorageThreadType, MastraMessageV1, MastraMessageV2 } from '@mastra/core/memory';
4
+ import type { StorageThreadType, MastraMessageV1 } from '@mastra/core/memory';
3
5
  import {
4
6
  MastraStorage,
5
7
  TABLE_MESSAGES,
6
8
  TABLE_THREADS,
9
+ TABLE_RESOURCES,
7
10
  TABLE_WORKFLOW_SNAPSHOT,
8
11
  TABLE_EVALS,
9
12
  TABLE_TRACES,
@@ -12,6 +15,7 @@ import type {
12
15
  TABLE_NAMES,
13
16
  StorageColumn,
14
17
  StorageGetMessagesArg,
18
+ StorageResourceType,
15
19
  EvalRow,
16
20
  WorkflowRuns,
17
21
  WorkflowRun,
@@ -21,7 +25,6 @@ import type {
21
25
  } from '@mastra/core/storage';
22
26
  import type { WorkflowRunState } from '@mastra/core/workflows';
23
27
  import { Redis } from '@upstash/redis';
24
- import { MessageList } from '../../../../packages/core/dist/agent/index.cjs';
25
28
 
26
29
  export interface UpstashConfig {
27
30
  url: string;
@@ -41,9 +44,11 @@ export class UpstashStore extends MastraStorage {
41
44
 
42
45
  public get supports(): {
43
46
  selectByIncludeResourceScope: boolean;
47
+ resourceWorkingMemory: boolean;
44
48
  } {
45
49
  return {
46
50
  selectByIncludeResourceScope: true,
51
+ resourceWorkingMemory: true,
47
52
  };
48
53
  }
49
54
 
@@ -734,16 +739,7 @@ export class UpstashStore extends MastraStorage {
734
739
  }: StorageGetMessagesArg & { format?: 'v1' | 'v2' }): Promise<MastraMessageV1[] | MastraMessageV2[]> {
735
740
  const threadMessagesKey = this.getThreadMessagesKey(threadId);
736
741
  const allMessageIds = await this.redis.zrange(threadMessagesKey, 0, -1);
737
- // When selectBy is undefined or selectBy.last is undefined, get ALL messages (not just 40)
738
- let limit: number;
739
- if (typeof selectBy?.last === 'number') {
740
- limit = Math.max(0, selectBy.last);
741
- } else if (selectBy?.last === false) {
742
- limit = 0;
743
- } else {
744
- // No limit specified - get all messages
745
- limit = Number.MAX_SAFE_INTEGER;
746
- }
742
+ const limit = this.resolveMessageLimit({ last: selectBy?.last, defaultLimit: Number.MAX_SAFE_INTEGER });
747
743
 
748
744
  const messageIds = new Set<string>();
749
745
  const messageIdToThreadIds: Record<string, string> = {};
@@ -1187,4 +1183,99 @@ export class UpstashStore extends MastraStorage {
1187
1183
  async close(): Promise<void> {
1188
1184
  // No explicit cleanup needed for Upstash Redis
1189
1185
  }
1186
+
1187
+ async updateMessages(_args: {
1188
+ messages: Partial<Omit<MastraMessageV2, 'createdAt'>> &
1189
+ {
1190
+ id: string;
1191
+ content?: { metadata?: MastraMessageContentV2['metadata']; content?: MastraMessageContentV2['content'] };
1192
+ }[];
1193
+ }): Promise<MastraMessageV2[]> {
1194
+ this.logger.error('updateMessages is not yet implemented in UpstashStore');
1195
+ throw new Error('Method not implemented');
1196
+ }
1197
+
1198
+ async getResourceById({ resourceId }: { resourceId: string }): Promise<StorageResourceType | null> {
1199
+ try {
1200
+ const key = `${TABLE_RESOURCES}:${resourceId}`;
1201
+ const data = await this.redis.get<StorageResourceType>(key);
1202
+
1203
+ if (!data) {
1204
+ return null;
1205
+ }
1206
+
1207
+ return {
1208
+ ...data,
1209
+ createdAt: new Date(data.createdAt),
1210
+ updatedAt: new Date(data.updatedAt),
1211
+ // Ensure workingMemory is always returned as a string, regardless of automatic parsing
1212
+ workingMemory: typeof data.workingMemory === 'object' ? JSON.stringify(data.workingMemory) : data.workingMemory,
1213
+ metadata: typeof data.metadata === 'string' ? JSON.parse(data.metadata) : data.metadata,
1214
+ };
1215
+ } catch (error) {
1216
+ this.logger.error('Error getting resource by ID:', error);
1217
+ throw error;
1218
+ }
1219
+ }
1220
+
1221
+ async saveResource({ resource }: { resource: StorageResourceType }): Promise<StorageResourceType> {
1222
+ try {
1223
+ const key = `${TABLE_RESOURCES}:${resource.id}`;
1224
+ const serializedResource = {
1225
+ ...resource,
1226
+ metadata: JSON.stringify(resource.metadata),
1227
+ createdAt: resource.createdAt.toISOString(),
1228
+ updatedAt: resource.updatedAt.toISOString(),
1229
+ };
1230
+
1231
+ await this.redis.set(key, serializedResource);
1232
+
1233
+ return resource;
1234
+ } catch (error) {
1235
+ this.logger.error('Error saving resource:', error);
1236
+ throw error;
1237
+ }
1238
+ }
1239
+
1240
+ async updateResource({
1241
+ resourceId,
1242
+ workingMemory,
1243
+ metadata,
1244
+ }: {
1245
+ resourceId: string;
1246
+ workingMemory?: string;
1247
+ metadata?: Record<string, unknown>;
1248
+ }): Promise<StorageResourceType> {
1249
+ try {
1250
+ const existingResource = await this.getResourceById({ resourceId });
1251
+
1252
+ if (!existingResource) {
1253
+ // Create new resource if it doesn't exist
1254
+ const newResource: StorageResourceType = {
1255
+ id: resourceId,
1256
+ workingMemory,
1257
+ metadata: metadata || {},
1258
+ createdAt: new Date(),
1259
+ updatedAt: new Date(),
1260
+ };
1261
+ return this.saveResource({ resource: newResource });
1262
+ }
1263
+
1264
+ const updatedResource = {
1265
+ ...existingResource,
1266
+ workingMemory: workingMemory !== undefined ? workingMemory : existingResource.workingMemory,
1267
+ metadata: {
1268
+ ...existingResource.metadata,
1269
+ ...metadata,
1270
+ },
1271
+ updatedAt: new Date(),
1272
+ };
1273
+
1274
+ await this.saveResource({ resource: updatedResource });
1275
+ return updatedResource;
1276
+ } catch (error) {
1277
+ this.logger.error('Error updating resource:', error);
1278
+ throw error;
1279
+ }
1280
+ }
1190
1281
  }
@@ -321,9 +321,9 @@ describe('UpstashStore', () => {
321
321
 
322
322
  it('should save and retrieve messages in order', async () => {
323
323
  const messages: MastraMessageV2[] = [
324
- createSampleMessageV2({ threadId, content: 'First' }),
325
- createSampleMessageV2({ threadId, content: 'Second' }),
326
- createSampleMessageV2({ threadId, content: 'Third' }),
324
+ createSampleMessageV2({ threadId, content: { content: 'First' } }),
325
+ createSampleMessageV2({ threadId, content: { content: 'Second' } }),
326
+ createSampleMessageV2({ threadId, content: { content: 'Third' } }),
327
327
  ];
328
328
 
329
329
  await store.saveMessages({ messages, format: 'v2' });
@@ -344,16 +344,48 @@ describe('UpstashStore', () => {
344
344
  await store.saveThread({ thread: thread3 });
345
345
 
346
346
  const messages: MastraMessageV2[] = [
347
- createSampleMessageV2({ threadId: 'thread-one', content: 'First', resourceId: 'cross-thread-resource' }),
348
- createSampleMessageV2({ threadId: 'thread-one', content: 'Second', resourceId: 'cross-thread-resource' }),
349
- createSampleMessageV2({ threadId: 'thread-one', content: 'Third', resourceId: 'cross-thread-resource' }),
347
+ createSampleMessageV2({
348
+ threadId: 'thread-one',
349
+ content: { content: 'First' },
350
+ resourceId: 'cross-thread-resource',
351
+ }),
352
+ createSampleMessageV2({
353
+ threadId: 'thread-one',
354
+ content: { content: 'Second' },
355
+ resourceId: 'cross-thread-resource',
356
+ }),
357
+ createSampleMessageV2({
358
+ threadId: 'thread-one',
359
+ content: { content: 'Third' },
360
+ resourceId: 'cross-thread-resource',
361
+ }),
350
362
 
351
- createSampleMessageV2({ threadId: 'thread-two', content: 'Fourth', resourceId: 'cross-thread-resource' }),
352
- createSampleMessageV2({ threadId: 'thread-two', content: 'Fifth', resourceId: 'cross-thread-resource' }),
353
- createSampleMessageV2({ threadId: 'thread-two', content: 'Sixth', resourceId: 'cross-thread-resource' }),
363
+ createSampleMessageV2({
364
+ threadId: 'thread-two',
365
+ content: { content: 'Fourth' },
366
+ resourceId: 'cross-thread-resource',
367
+ }),
368
+ createSampleMessageV2({
369
+ threadId: 'thread-two',
370
+ content: { content: 'Fifth' },
371
+ resourceId: 'cross-thread-resource',
372
+ }),
373
+ createSampleMessageV2({
374
+ threadId: 'thread-two',
375
+ content: { content: 'Sixth' },
376
+ resourceId: 'cross-thread-resource',
377
+ }),
354
378
 
355
- createSampleMessageV2({ threadId: 'thread-three', content: 'Seventh', resourceId: 'other-resource' }),
356
- createSampleMessageV2({ threadId: 'thread-three', content: 'Eighth', resourceId: 'other-resource' }),
379
+ createSampleMessageV2({
380
+ threadId: 'thread-three',
381
+ content: { content: 'Seventh' },
382
+ resourceId: 'other-resource',
383
+ }),
384
+ createSampleMessageV2({
385
+ threadId: 'thread-three',
386
+ content: { content: 'Eighth' },
387
+ resourceId: 'other-resource',
388
+ }),
357
389
  ];
358
390
 
359
391
  await store.saveMessages({ messages: messages, format: 'v2' });
@@ -472,7 +504,7 @@ describe('UpstashStore', () => {
472
504
  await store.saveThread({ thread });
473
505
 
474
506
  const messages = Array.from({ length: 15 }, (_, i) =>
475
- createSampleMessageV2({ threadId: thread.id, content: `Message ${i + 1}` }),
507
+ createSampleMessageV2({ threadId: thread.id, content: { content: `Message ${i + 1}` } }),
476
508
  );
477
509
 
478
510
  await store.saveMessages({ messages, format: 'v2' });
@@ -512,7 +544,7 @@ describe('UpstashStore', () => {
512
544
  await store.saveThread({ thread });
513
545
 
514
546
  const messages = Array.from({ length: 10 }, (_, i) => {
515
- const message = createSampleMessageV2({ threadId: thread.id, content: `Message ${i + 1}` });
547
+ const message = createSampleMessageV2({ threadId: thread.id, content: { content: `Message ${i + 1}` } });
516
548
  // Ensure different timestamps
517
549
  message.createdAt = new Date(Date.now() + i * 1000);
518
550
  return message;
@@ -545,13 +577,13 @@ describe('UpstashStore', () => {
545
577
  const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
546
578
 
547
579
  const oldMessages = Array.from({ length: 3 }, (_, i) => {
548
- const message = createSampleMessageV2({ threadId: thread.id, content: `Old Message ${i + 1}` });
580
+ const message = createSampleMessageV2({ threadId: thread.id, content: { content: `Old Message ${i + 1}` } });
549
581
  message.createdAt = yesterday;
550
582
  return message;
551
583
  });
552
584
 
553
585
  const newMessages = Array.from({ length: 4 }, (_, i) => {
554
- const message = createSampleMessageV2({ threadId: thread.id, content: `New Message ${i + 1}` });
586
+ const message = createSampleMessageV2({ threadId: thread.id, content: { content: `New Message ${i + 1}` } });
555
587
  message.createdAt = tomorrow;
556
588
  return message;
557
589
  });
@@ -18,6 +18,12 @@ import { UpstashFilterTranslator } from './filter';
18
18
  export class UpstashVector extends MastraVector {
19
19
  private client: Index;
20
20
 
21
+ /**
22
+ * Creates a new UpstashVector instance.
23
+ * @param {object} params - The parameters for the UpstashVector.
24
+ * @param {string} params.url - The URL of the Upstash vector index.
25
+ * @param {string} params.token - The token for the Upstash vector index.
26
+ */
21
27
  constructor({ url, token }: { url: string; token: string }) {
22
28
  super();
23
29
  this.client = new Index({
@@ -26,7 +32,12 @@ export class UpstashVector extends MastraVector {
26
32
  });
27
33
  }
28
34
 
29
- async upsert({ indexName, vectors, metadata, ids }: UpsertVectorParams): Promise<string[]> {
35
+ /**
36
+ * Upserts vectors into the index.
37
+ * @param {UpsertVectorParams} params - The parameters for the upsert operation.
38
+ * @returns {Promise<string[]>} A promise that resolves to the IDs of the upserted vectors.
39
+ */
40
+ async upsert({ indexName: namespace, vectors, metadata, ids }: UpsertVectorParams): Promise<string[]> {
30
41
  const generatedIds = ids || vectors.map(() => crypto.randomUUID());
31
42
 
32
43
  const points = vectors.map((vector, index) => ({
@@ -36,28 +47,43 @@ export class UpstashVector extends MastraVector {
36
47
  }));
37
48
 
38
49
  await this.client.upsert(points, {
39
- namespace: indexName,
50
+ namespace,
40
51
  });
41
52
  return generatedIds;
42
53
  }
43
54
 
55
+ /**
56
+ * Transforms a Mastra vector filter into an Upstash-compatible filter string.
57
+ * @param {VectorFilter} [filter] - The filter to transform.
58
+ * @returns {string | undefined} The transformed filter string, or undefined if no filter is provided.
59
+ */
44
60
  transformFilter(filter?: VectorFilter) {
45
61
  const translator = new UpstashFilterTranslator();
46
62
  return translator.translate(filter);
47
63
  }
48
64
 
65
+ /**
66
+ * Creates a new index. For Upstash, this is a no-op as indexes (known as namespaces in Upstash) are created on-the-fly.
67
+ * @param {CreateIndexParams} _params - The parameters for creating the index (ignored).
68
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
69
+ */
49
70
  async createIndex(_params: CreateIndexParams): Promise<void> {
50
- console.log('No need to call createIndex for Upstash');
71
+ this.logger.debug('No need to call createIndex for Upstash');
51
72
  }
52
73
 
74
+ /**
75
+ * Queries the vector index.
76
+ * @param {QueryVectorParams} params - The parameters for the query operation. indexName is the namespace in Upstash.
77
+ * @returns {Promise<QueryResult[]>} A promise that resolves to the query results.
78
+ */
53
79
  async query({
54
- indexName,
80
+ indexName: namespace,
55
81
  queryVector,
56
82
  topK = 10,
57
83
  filter,
58
84
  includeVector = false,
59
85
  }: QueryVectorParams): Promise<QueryResult[]> {
60
- const ns = this.client.namespace(indexName);
86
+ const ns = this.client.namespace(namespace);
61
87
 
62
88
  const filterString = this.transformFilter(filter);
63
89
  const results = await ns.query({
@@ -77,6 +103,10 @@ export class UpstashVector extends MastraVector {
77
103
  }));
78
104
  }
79
105
 
106
+ /**
107
+ * Lists all namespaces in the Upstash vector index, which correspond to indexes.
108
+ * @returns {Promise<string[]>} A promise that resolves to a list of index names.
109
+ */
80
110
  async listIndexes(): Promise<string[]> {
81
111
  const indexes = await this.client.listNamespaces();
82
112
  return indexes.filter(Boolean);
@@ -85,30 +115,35 @@ export class UpstashVector extends MastraVector {
85
115
  /**
86
116
  * Retrieves statistics about a vector index.
87
117
  *
88
- * @param {string} indexName - The name of the index to describe
118
+ * @param {string} indexName - The name of the namespace to describe
89
119
  * @returns A promise that resolves to the index statistics including dimension, count and metric
90
120
  */
91
- async describeIndex({ indexName }: DescribeIndexParams): Promise<IndexStats> {
121
+ async describeIndex({ indexName: namespace }: DescribeIndexParams): Promise<IndexStats> {
92
122
  const info = await this.client.info();
93
123
 
94
124
  return {
95
125
  dimension: info.dimension,
96
- count: info.namespaces?.[indexName]?.vectorCount || 0,
126
+ count: info.namespaces?.[namespace]?.vectorCount || 0,
97
127
  metric: info?.similarityFunction?.toLowerCase() as 'cosine' | 'euclidean' | 'dotproduct',
98
128
  };
99
129
  }
100
130
 
101
- async deleteIndex({ indexName }: DeleteIndexParams): Promise<void> {
131
+ /**
132
+ * Deletes an index (namespace).
133
+ * @param {DeleteIndexParams} params - The parameters for the delete operation.
134
+ * @returns {Promise<void>} A promise that resolves when the deletion is complete.
135
+ */
136
+ async deleteIndex({ indexName: namespace }: DeleteIndexParams): Promise<void> {
102
137
  try {
103
- await this.client.deleteNamespace(indexName);
138
+ await this.client.deleteNamespace(namespace);
104
139
  } catch (error) {
105
- console.error('Failed to delete namespace:', error);
140
+ this.logger.error('Failed to delete namespace:', error);
106
141
  }
107
142
  }
108
143
 
109
144
  /**
110
145
  * Updates a vector by its ID with the provided vector and/or metadata.
111
- * @param indexName - The name of the index containing the vector.
146
+ * @param indexName - The name of the namespace containing the vector.
112
147
  * @param id - The ID of the vector to update.
113
148
  * @param update - An object containing the vector and/or metadata to update.
114
149
  * @param update.vector - An optional array of numbers representing the new vector.
@@ -116,7 +151,7 @@ export class UpstashVector extends MastraVector {
116
151
  * @returns A promise that resolves when the update is complete.
117
152
  * @throws Will throw an error if no updates are provided or if the update operation fails.
118
153
  */
119
- async updateVector({ indexName, id, update }: UpdateVectorParams): Promise<void> {
154
+ async updateVector({ indexName: namespace, id, update }: UpdateVectorParams): Promise<void> {
120
155
  try {
121
156
  if (!update.vector && !update.metadata) {
122
157
  throw new Error('No update data provided');
@@ -143,27 +178,27 @@ export class UpstashVector extends MastraVector {
143
178
  };
144
179
 
145
180
  await this.client.upsert(points, {
146
- namespace: indexName,
181
+ namespace,
147
182
  });
148
183
  } catch (error: any) {
149
- throw new Error(`Failed to update vector by id: ${id} for index name: ${indexName}: ${error.message}`);
184
+ throw new Error(`Failed to update vector by id: ${id} for index name: ${namespace}: ${error.message}`);
150
185
  }
151
186
  }
152
187
 
153
188
  /**
154
189
  * Deletes a vector by its ID.
155
- * @param indexName - The name of the index containing the vector.
190
+ * @param indexName - The name of the namespace containing the vector.
156
191
  * @param id - The ID of the vector to delete.
157
192
  * @returns A promise that resolves when the deletion is complete.
158
193
  * @throws Will throw an error if the deletion operation fails.
159
194
  */
160
- async deleteVector({ indexName, id }: DeleteVectorParams): Promise<void> {
195
+ async deleteVector({ indexName: namespace, id }: DeleteVectorParams): Promise<void> {
161
196
  try {
162
197
  await this.client.delete(id, {
163
- namespace: indexName,
198
+ namespace,
164
199
  });
165
200
  } catch (error) {
166
- console.error(`Failed to delete vector by id: ${id} for index name: ${indexName}:`, error);
201
+ this.logger.error(`Failed to delete vector by id: ${id} for namespace: ${namespace}:`, error);
167
202
  }
168
203
  }
169
204
  }
@@ -1,39 +0,0 @@
1
-
2
- > @mastra/upstash@0.11.0-alpha.0 build /home/runner/work/mastra/mastra/stores/upstash
3
- > tsup src/index.ts --format esm,cjs --experimental-dts --clean --treeshake=smallest --splitting
4
-
5
- CLI Building entry: src/index.ts
6
- CLI Using tsconfig: tsconfig.json
7
- CLI tsup v8.5.0
8
- TSC Build start
9
- TSC ⚡️ Build success in 9798ms
10
- DTS Build start
11
- CLI Target: es2022
12
- Analysis will use the bundled TypeScript version 5.8.3
13
- Writing package typings: /home/runner/work/mastra/mastra/stores/upstash/dist/_tsup-dts-rollup.d.ts
14
- Analysis will use the bundled TypeScript version 5.8.3
15
- Writing package typings: /home/runner/work/mastra/mastra/stores/upstash/dist/_tsup-dts-rollup.d.cts
16
- DTS ⚡️ Build success in 10791ms
17
- CLI Cleaning output folder
18
- ESM Build start
19
- CJS Build start
20
- dist/index.cjs (1729:18): Use of eval in "dist/index.cjs" is strongly discouraged as it poses security risks and may cause issues with minification.
21
- dist/index.js (1729:18): Use of eval in "dist/index.js" is strongly discouraged as it poses security risks and may cause issues with minification.
22
- CJS dist/getMachineId-darwin-3PL23DL6.cjs 1.19 KB
23
- CJS dist/getMachineId-linux-KYLPK3HC.cjs 813.00 B
24
- CJS dist/getMachineId-bsd-HDZ73WR7.cjs 1015.00 B
25
- CJS dist/getMachineId-win-ZTI2LRDJ.cjs 1.66 KB
26
- CJS dist/chunk-N2CPQVE3.cjs 1.09 KB
27
- CJS dist/getMachineId-unsupported-DEDJN4ZS.cjs 777.00 B
28
- CJS dist/chunk-U74OJRHU.cjs 62.08 KB
29
- CJS dist/index.cjs 1.67 MB
30
- CJS ⚡️ Build success in 23953ms
31
- ESM dist/getMachineId-darwin-UTKBTJ2U.js 1.08 KB
32
- ESM dist/getMachineId-linux-K3QXQYAB.js 740.00 B
33
- ESM dist/getMachineId-bsd-KKIDU47O.js 896.00 B
34
- ESM dist/getMachineId-win-L2EYIM5A.js 1.04 KB
35
- ESM dist/chunk-IGKEDEDE.js 452.00 B
36
- ESM dist/getMachineId-unsupported-VPWBQCK7.js 700.00 B
37
- ESM dist/chunk-HSTZWXH7.js 61.52 KB
38
- ESM dist/index.js 1.66 MB
39
- ESM ⚡️ Build success in 23954ms