@stackshift-cloud/sdk 1.3.0 → 1.3.1

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.
@@ -209,10 +209,27 @@ export interface AssetTextExtractionInput {
209
209
  language?: string;
210
210
  options?: Record<string, JsonValue>;
211
211
  }
212
- export type AssetMigrationReport = {
212
+ export interface AssetMigrationReconciliation {
213
+ discovered: number;
214
+ imported: number;
215
+ skipped: number;
216
+ failed: number;
217
+ canceled: number;
218
+ unresolved: number;
219
+ mapped: number;
220
+ verified_checksums: number;
221
+ missing_checksums: number;
222
+ warnings: Record<string, number>;
223
+ inventory_reconciled: boolean;
224
+ delivery_verified: boolean;
225
+ cutover_ready: boolean;
226
+ next_actions: string[];
227
+ }
228
+ export interface AssetMigrationReport {
213
229
  migration: AssetMigration;
214
230
  items: AssetMigrationItem[];
215
- } | Record<string, JsonValue>;
231
+ reconciliation?: AssetMigrationReconciliation;
232
+ }
216
233
  export type AssetCapabilityUploadResult = Asset;
217
234
  export type AssetStorageBucket = AssetBucket;
218
235
  export {};
package/dist/index.d.ts CHANGED
@@ -6,6 +6,10 @@ export type * from './asset-media-v2.js';
6
6
  export { AssetConnectionsClient, AssetMigrationsClient, AssetStorageClient, AssetTextClient, AssetUploadCapabilitiesClient } from './assets-platform.js';
7
7
  export type * from './assets-platform-types.js';
8
8
  export { ChunkedUploadController, UploadResumeInvalidError, putBlob, sha256Blob } from './upload-controller.js';
9
+ export { S2UploadsClient } from './s2.js';
10
+ export type { S2Object } from './s2.js';
11
+ export { S2Uploader, createS2ResumeStore } from './s2-upload.js';
12
+ export type { S2UploadBackend } from './s2-upload.js';
9
13
  export type { ChunkUploadCallbacks, ChunkUploadRequest, ChunkUploadSession, ChunkUploadTransport, ChunkedUploadOptions, UploadProgress, UploadResumeRecord, UploadResumeStore, UploadedPart } from './upload-controller.js';
10
14
  export { createResponsiveImage, namedResponsiveSource, uploadBrowserFile } from './assets-framework.js';
11
15
  export type { ArtDirectionSource, ResponsiveImageDescriptor, ResponsiveImageOptions } from './assets-framework.js';
@@ -26,7 +30,7 @@ export type { MailSendTemplateInput, MailSendTemplateResponse, MailTemplate, Mai
26
30
  export type { MailEvent, MailEventListOptions, MailEventListResult, MailEventType, MailTimelineItem, MailWebhook, MailWebhookCreateInput, MailWebhookCreateResponse, MailWebhookDelivery, MailWebhookDeliveryAttempt, MailWebhookDeliveryDetail, MailWebhookDeliveryListOptions, MailWebhookListOptions, MailWebhookListResult, MailWebhookUpdateInput, MailWebhookReplay, MailWebhookReplayInput, } from './mail-webhook-types.js';
27
31
  export type { MailMessageStream, MailMessageStreamCreateInput, MailMessageStreamListResult } from './mail-streams.js';
28
32
  export type { MailDomainReputation, MailLimits, MailManualReviewStatus, MailReputation, MailReputationEvent, MailReputationEventListOptions, MailReputationEventListResult, MailReputationEventSeverity, MailReputationStatus, MailReputationTier, MailReputationWorkspace, } from './mail-reputation-types.js';
29
- export type { MailAnalytics, MailAnalyticsInput, MailAttachmentInput, MailBatch, MailBatchItem, MailBatchSendInput, MailBatchTemplateInput, MailInboundDomain, MailInboundMessage, MailScheduledCreateInput, MailScheduledMessage, } from './mail-advanced-types.js';
33
+ export type { MailAnalytics, MailAnalyticsRateCohort, MailAnalyticsInput, MailAttachmentInput, MailBatch, MailBatchItem, MailBatchSendInput, MailBatchTemplateInput, MailInboundDomain, MailInboundMessage, MailScheduledCreateInput, MailScheduledMessage, } from './mail-advanced-types.js';
30
34
  export { AssetVideoClient } from './assets-video.js';
31
35
  export type * from './assets-video-types.js';
32
36
  export * from './mail-tracking.js';
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ export { AssetsClient, assetTransformOptions, assetTransformPresets, assetTransf
3
3
  export { AssetWorkflowsClient } from './asset-workflows.js';
4
4
  export { AssetConnectionsClient, AssetMigrationsClient, AssetStorageClient, AssetTextClient, AssetUploadCapabilitiesClient } from './assets-platform.js';
5
5
  export { ChunkedUploadController, UploadResumeInvalidError, putBlob, sha256Blob } from './upload-controller.js';
6
+ export { S2UploadsClient } from './s2.js';
7
+ export { S2Uploader, createS2ResumeStore } from './s2-upload.js';
6
8
  export { createResponsiveImage, namedResponsiveSource, uploadBrowserFile } from './assets-framework.js';
7
9
  export { assetContractOperations } from './generated/assets-contract.js';
8
10
  export { MailBouncesClient, MailClient, MailDomainsClient, MailEventsClient, MailLimitsClient, MailMessagesClient, MailScheduledClient, MailBatchClient, MailInboundClient, MailAnalyticsClient, MailOtpChallengesClient, MailOtpClient, MailReputationClient, MailReputationEventsClient, MailSuppressionsClient, MailTemplatesClient, MailWebhooksClient, MailStreamsClient, MailAudiencesClient, MailCampaignsClient, } from './mail.js';
@@ -107,7 +107,18 @@ export interface MailAnalyticsInput {
107
107
  template?: string;
108
108
  event?: string;
109
109
  }
110
+ export interface MailAnalyticsRateCohort {
111
+ basis: 'recipient_created_at';
112
+ asOf: string;
113
+ queued: number;
114
+ mtaAccepted: number;
115
+ delivered: number;
116
+ bounced: number;
117
+ failed: number;
118
+ }
110
119
  export interface MailAnalytics {
120
+ rateCohort?: MailAnalyticsRateCohort;
121
+ previousRateCohort?: MailAnalyticsRateCohort;
111
122
  summary: Record<string, number>;
112
123
  series: Array<Record<string, number | string>>;
113
124
  }
@@ -1,6 +1,10 @@
1
1
  import type { StackShift } from './client.js';
2
2
  import type { MailMode } from './mail-simulation.js';
3
3
  export interface MailBillingSummary {
4
+ catalogId?: string;
5
+ planSlug?: string;
6
+ monthlyPriceKobo?: number;
7
+ overageAllowed?: boolean;
4
8
  reconciliationRequired: boolean;
5
9
  active: boolean;
6
10
  mode: MailMode;
@@ -56,12 +56,19 @@ export declare class MailFormsClient {
56
56
  data: MailSignupForm[];
57
57
  }>;
58
58
  }
59
+ export interface MailSegmentCondition {
60
+ field: string;
61
+ operator: 'equals' | 'not_equals' | 'contains' | 'exists';
62
+ value?: string;
63
+ }
59
64
  export interface MailSegmentInput {
65
+ match?: 'all' | 'any';
66
+ conditions?: MailSegmentCondition[];
60
67
  audienceId: string;
61
68
  name: string;
62
69
  description?: string;
63
- field: string;
64
- operator: string;
70
+ field?: string;
71
+ operator?: string;
65
72
  value?: string;
66
73
  }
67
74
  export interface MailSegment extends MailSegmentInput {
@@ -0,0 +1,22 @@
1
+ import type { ChunkUploadRequest, ChunkUploadSession, ChunkedUploadOptions, UploadedPart, UploadResumeStore } from './upload-controller.js';
2
+ export interface S2UploadBackend<TResult> {
3
+ create(input: ChunkUploadRequest & {
4
+ size: number;
5
+ }): Promise<ChunkUploadSession>;
6
+ resume(context: string): Promise<ChunkUploadSession>;
7
+ signPart(session: ChunkUploadSession, part: UploadedPart): Promise<{
8
+ url: string;
9
+ headers?: Record<string, string>;
10
+ }>;
11
+ complete(session: ChunkUploadSession): Promise<TResult>;
12
+ cancel(session: ChunkUploadSession): Promise<void>;
13
+ }
14
+ /** Bind these callbacks to authenticated application-server routes. Never put permanent S3 credentials in a browser. */
15
+ export declare class S2Uploader<TResult> {
16
+ private readonly controller;
17
+ constructor(backend: S2UploadBackend<TResult>, store?: UploadResumeStore);
18
+ upload(file: Blob, request: ChunkUploadRequest, options?: ChunkedUploadOptions): Promise<TResult>;
19
+ cancel(resumeKey: string): Promise<void>;
20
+ }
21
+ /** Use an account-specific namespace, and clear it on logout. Stored sessions contain temporary upload capabilities. */
22
+ export declare function createS2ResumeStore(storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>, namespace: string): UploadResumeStore;
@@ -0,0 +1,49 @@
1
+ import { ChunkedUploadController, putBlob } from './upload-controller.js';
2
+ /** Bind these callbacks to authenticated application-server routes. Never put permanent S3 credentials in a browser. */
3
+ export class S2Uploader {
4
+ controller;
5
+ constructor(backend, store) {
6
+ this.controller = new ChunkedUploadController({
7
+ create: (file, request) => backend.create({ ...request, size: file.size }),
8
+ resume: (context) => backend.resume(context),
9
+ uploadPart: async (session, partNumber, chunk, checksumSHA256, callbacks) => {
10
+ const signed = await backend.signPart(session, { partNumber, size: chunk.size, checksumSHA256 });
11
+ await putBlob(signed.url, chunk, callbacks, signed.headers);
12
+ },
13
+ complete: (session) => backend.complete(session),
14
+ cancel: (session) => backend.cancel(session),
15
+ }, store);
16
+ }
17
+ upload(file, request, options = {}) {
18
+ if (file.size === 0)
19
+ return Promise.reject(new Error('Use a single presigned PUT for an empty object'));
20
+ return this.controller.upload(file, request, { ...options, requireMatchingFile: true });
21
+ }
22
+ cancel(resumeKey) { return this.controller.cancel(resumeKey); }
23
+ }
24
+ /** Use an account-specific namespace, and clear it on logout. Stored sessions contain temporary upload capabilities. */
25
+ export function createS2ResumeStore(storage, namespace) {
26
+ if (!namespace.trim())
27
+ throw new Error('An account-specific upload namespace is required');
28
+ const key = (value) => `stackshift:s2:${encodeURIComponent(namespace)}:${encodeURIComponent(value)}`;
29
+ return {
30
+ async get(value) {
31
+ const raw = storage.getItem(key(value));
32
+ if (raw === null)
33
+ return undefined;
34
+ let record;
35
+ try {
36
+ record = JSON.parse(raw);
37
+ }
38
+ catch {
39
+ throw new Error('Saved upload state is unreadable; clear it before starting again');
40
+ }
41
+ if (!record || record.key !== value || typeof record.fingerprint !== 'string' || !record.session?.id) {
42
+ throw new Error('Saved upload state is invalid; clear it before starting again');
43
+ }
44
+ return record;
45
+ },
46
+ async set(record) { storage.setItem(key(record.key), JSON.stringify(record)); },
47
+ async delete(value) { storage.removeItem(key(value)); },
48
+ };
49
+ }
package/dist/s2.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { StackShift } from './client.js';
2
+ import type { S2UploadBackend } from './s2-upload.js';
3
+ import type { ChunkUploadSession, UploadedPart } from './upload-controller.js';
4
+ export interface S2Object {
5
+ id: string;
6
+ version_id: string;
7
+ bucket_id: string;
8
+ key: string;
9
+ size: number;
10
+ etag: string;
11
+ checksum_sha256: string;
12
+ content_type: string;
13
+ }
14
+ /** Server-side adapter. Authorize the application's user and destination before forwarding browser requests. */
15
+ export declare class S2UploadsClient implements S2UploadBackend<S2Object> {
16
+ private readonly client;
17
+ private readonly path;
18
+ constructor(client: StackShift, bucketID: string);
19
+ create(input: {
20
+ key: string;
21
+ size: number;
22
+ mimeType: string;
23
+ }): Promise<ChunkUploadSession>;
24
+ resume(context: string): Promise<ChunkUploadSession>;
25
+ signPart(session: ChunkUploadSession, part: UploadedPart): Promise<{
26
+ url: string;
27
+ headers: {
28
+ [k: string]: string;
29
+ };
30
+ }>;
31
+ complete(session: ChunkUploadSession): Promise<S2Object>;
32
+ cancel(session: ChunkUploadSession): Promise<void>;
33
+ private action;
34
+ }
package/dist/s2.js ADDED
@@ -0,0 +1,31 @@
1
+ /** Server-side adapter. Authorize the application's user and destination before forwarding browser requests. */
2
+ export class S2UploadsClient {
3
+ client;
4
+ path;
5
+ constructor(client, bucketID) {
6
+ this.client = client;
7
+ if (!bucketID)
8
+ throw new Error('A bucket ID is required');
9
+ this.path = `/buckets/${encodeURIComponent(bucketID)}/uploads`;
10
+ }
11
+ create(input) {
12
+ return this.client.request(this.path, { method: 'POST', body: input });
13
+ }
14
+ resume(context) {
15
+ return this.action('resume', context);
16
+ }
17
+ async signPart(session, part) {
18
+ const result = await this.action('sign-part', session.uploadToken, part);
19
+ const headers = Object.fromEntries(Object.entries(result.headers ?? {}).map(([name, values]) => [name, values.join(', ')]));
20
+ return { url: result.url, headers };
21
+ }
22
+ complete(session) {
23
+ return this.action('complete', session.uploadToken);
24
+ }
25
+ async cancel(session) {
26
+ await this.action('cancel', session.uploadToken);
27
+ }
28
+ action(action, token, part) {
29
+ return this.client.request(`${this.path}/actions`, { method: 'POST', body: { action, token, part } });
30
+ }
31
+ }
package/dist/types.d.ts CHANGED
@@ -197,6 +197,18 @@ export interface ProjectListOptions {
197
197
  perPage?: number;
198
198
  }
199
199
  export interface Asset {
200
+ readiness?: {
201
+ state: "ready" | "processing" | "blocked" | "unknown";
202
+ stages: Array<{
203
+ id: string;
204
+ status: string;
205
+ }>;
206
+ actions: Record<"preview" | "download" | "edit" | "publish", {
207
+ state: "ready" | "blocked" | "unknown";
208
+ code: string;
209
+ message: string;
210
+ }>;
211
+ };
200
212
  id: string;
201
213
  bucket: string;
202
214
  key: string;
@@ -9,6 +9,7 @@ export interface UploadedPart {
9
9
  checksumSHA256?: string;
10
10
  }
11
11
  export interface ChunkUploadSession {
12
+ completionPending?: boolean;
12
13
  id: string;
13
14
  uploadToken: string;
14
15
  expiresAt: string;
@@ -52,6 +53,7 @@ export interface ChunkedUploadOptions extends ChunkUploadCallbacks {
52
53
  concurrency?: number;
53
54
  retryAttempts?: number;
54
55
  cancelOnAbort?: boolean;
56
+ requireMatchingFile?: boolean;
55
57
  }
56
58
  export declare class UploadResumeInvalidError extends Error {
57
59
  constructor(message?: string);
@@ -16,13 +16,13 @@ export class ChunkedUploadController {
16
16
  throwIfAborted(options.signal);
17
17
  const resumeKey = options.resumeKey;
18
18
  const fingerprint = fileFingerprint(file, request);
19
- let session = await this.restore(resumeKey, fingerprint);
19
+ let session = await this.restore(resumeKey, fingerprint, options.requireMatchingFile);
20
20
  if (!session)
21
21
  session = await this.transport.create(file, request);
22
22
  validateSession(session, file.size);
23
23
  options.onSession?.(session);
24
24
  await this.persist(resumeKey, fingerprint, session);
25
- const completed = await verifiedCompletedParts(file, session);
25
+ const completed = await verifiedCompletedParts(file, session, options.requireMatchingFile);
26
26
  const loadedByPart = new Map(completed);
27
27
  const tasks = [];
28
28
  for (let offset = 0, partNumber = 1; offset < file.size; offset += session.partSize, partNumber += 1) {
@@ -37,8 +37,9 @@ export class ChunkedUploadController {
37
37
  const concurrency = boundedInteger(options.concurrency, 4, 1, 8);
38
38
  const attempts = boundedInteger(options.retryAttempts, 4, 1, 8);
39
39
  let next = 0;
40
+ let workerFailed = false;
40
41
  const worker = async () => {
41
- while (next < tasks.length) {
42
+ while (!workerFailed && next < tasks.length) {
42
43
  throwIfAborted(options.signal);
43
44
  const task = tasks[next++];
44
45
  const checksum = await sha256Blob(task.chunk);
@@ -61,7 +62,16 @@ export class ChunkedUploadController {
61
62
  }
62
63
  };
63
64
  try {
64
- await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length || 1) }, worker));
65
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(concurrency, tasks.length || 1) }, () => worker().catch((error) => {
66
+ workerFailed = true;
67
+ throw error;
68
+ })));
69
+ const failed = workers.find((result) => result.status === 'rejected');
70
+ if (failed?.status === 'rejected')
71
+ throw failed.reason;
72
+ throwIfAborted(options.signal);
73
+ session.completionPending = true;
74
+ await this.persist(resumeKey, fingerprint, session);
65
75
  const result = await retryWithBackoff(() => this.transport.complete(session), attempts, options.signal);
66
76
  if (resumeKey)
67
77
  await this.store?.delete(resumeKey);
@@ -69,9 +79,12 @@ export class ChunkedUploadController {
69
79
  }
70
80
  catch (error) {
71
81
  if (isAbortError(error) && options.cancelOnAbort) {
72
- await this.transport.cancel(session).catch(() => undefined);
73
- if (resumeKey)
74
- await this.store?.delete(resumeKey);
82
+ try {
83
+ await this.transport.cancel(session);
84
+ if (resumeKey)
85
+ await this.store?.delete(resumeKey);
86
+ }
87
+ catch { /* Retain recovery state when remote cancellation is uncertain. */ }
75
88
  }
76
89
  throw error;
77
90
  }
@@ -80,19 +93,24 @@ export class ChunkedUploadController {
80
93
  const record = await this.store?.get(resumeKey);
81
94
  if (!record)
82
95
  return;
83
- try {
84
- await this.transport.cancel(record.session);
85
- }
86
- finally {
87
- await this.store?.delete(resumeKey);
88
- }
96
+ await this.transport.cancel(record.session);
97
+ await this.store?.delete(resumeKey);
89
98
  }
90
- async restore(resumeKey, fingerprint) {
99
+ async restore(resumeKey, fingerprint, requireMatchingFile = false) {
91
100
  if (!resumeKey || !this.store)
92
101
  return undefined;
93
102
  const record = await this.store.get(resumeKey);
94
103
  if (!record)
95
104
  return undefined;
105
+ if (record.fingerprint !== fingerprint && requireMatchingFile) {
106
+ throw new Error('Reselect the original file to resume this upload, or cancel the saved upload first');
107
+ }
108
+ if (record.fingerprint === fingerprint && record.session.completionPending) {
109
+ if (Date.parse(record.session.expiresAt) <= Date.now()) {
110
+ throw new Error('Completion is uncertain and its recovery window expired; verify the destination before clearing saved state');
111
+ }
112
+ return record.session;
113
+ }
96
114
  if (record.fingerprint !== fingerprint || Date.parse(record.session.expiresAt) <= Date.now()) {
97
115
  await this.transport.cancel(record.session).catch(() => undefined);
98
116
  await this.store.delete(resumeKey);
@@ -117,7 +135,7 @@ export class ChunkedUploadController {
117
135
  session: { ...session, parts: session.parts.map((part) => ({ ...part })) },
118
136
  updatedAt: new Date().toISOString(),
119
137
  };
120
- this.persistence = this.persistence.then(() => this.store.set(record));
138
+ this.persistence = this.persistence.catch(() => undefined).then(() => this.store.set(record));
121
139
  return this.persistence;
122
140
  }
123
141
  }
@@ -154,9 +172,10 @@ export async function sha256Blob(blob) {
154
172
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
155
173
  }
156
174
  function validateSession(session, size) {
157
- if (!session.id || !session.uploadToken || session.partSize <= 0)
175
+ if (!session.id || !session.uploadToken || !Number.isSafeInteger(session.partSize) || session.partSize <= 0 ||
176
+ !Number.isSafeInteger(session.maxBytes) || session.maxBytes < 0 || !Array.isArray(session.parts))
158
177
  throw new Error('Upload session is incomplete');
159
- if (Date.parse(session.expiresAt) <= Date.now())
178
+ if (!Number.isFinite(Date.parse(session.expiresAt)) || Date.parse(session.expiresAt) <= Date.now())
160
179
  throw new Error('Upload session has expired');
161
180
  if (size > session.maxBytes)
162
181
  throw new Error(`File exceeds upload limit of ${session.maxBytes} bytes`);
@@ -167,7 +186,7 @@ function expectedPartSize(fileSize, partSize, partNumber) {
167
186
  return -1;
168
187
  return Math.min(partSize, fileSize - offset);
169
188
  }
170
- async function verifiedCompletedParts(file, session) {
189
+ async function verifiedCompletedParts(file, session, requireMatchingFile = false) {
171
190
  const completed = new Map();
172
191
  for (const part of session.parts) {
173
192
  const expectedSize = expectedPartSize(file.size, session.partSize, part.partNumber);
@@ -178,6 +197,9 @@ async function verifiedCompletedParts(file, session) {
178
197
  if (checksum.toLowerCase() === part.checksumSHA256.toLowerCase()) {
179
198
  completed.set(part.partNumber, part.size);
180
199
  }
200
+ else if (requireMatchingFile) {
201
+ throw new Error('The selected file differs from an uploaded part; reselect the original file or cancel this upload');
202
+ }
181
203
  }
182
204
  return completed;
183
205
  }
@@ -195,6 +217,7 @@ function boundedInteger(value, fallback, minimum, maximum) {
195
217
  async function retryWithBackoff(operation, attempts, signal) {
196
218
  let lastError;
197
219
  for (let attempt = 0; attempt < attempts; attempt += 1) {
220
+ throwIfAborted(signal);
198
221
  try {
199
222
  return await operation();
200
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackshift-cloud/sdk",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Official JavaScript and TypeScript SDK for StackShift.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
- "test": "npm run build && node test/mail.test.mjs && node test/mail-simulation.test.mjs && node test/mail-platform.test.mjs && node test/mail-webhooks.test.mjs && node test/mail-advanced.test.mjs && node test/mail-tracking.test.mjs && node test/mail-subscribers.test.mjs && node test/mail-exports.test.mjs && node test/mail-diagnostics.test.mjs && node test/assets.test.mjs && node test/assets-platform.test.mjs && node test/assets-video.test.mjs && node test/assets-dam.test.mjs && node test/ai-credits.test.mjs"
20
+ "test": "npm run build && node test/mail.test.mjs && node test/mail-simulation.test.mjs && node test/mail-platform.test.mjs && node test/mail-webhooks.test.mjs && node test/mail-advanced.test.mjs && node test/mail-tracking.test.mjs && node test/mail-subscribers.test.mjs && node test/mail-exports.test.mjs && node test/mail-diagnostics.test.mjs && node test/assets.test.mjs && node test/assets-platform.test.mjs && node test/assets-video.test.mjs && node test/assets-dam.test.mjs && node test/ai-credits.test.mjs && node --test test/s2-upload.test.mjs"
21
21
  },
22
22
  "devDependencies": {
23
23
  "typescript": "^5.4.0"