@deepraxis/sdk 0.1.0-dev.c6c496f → 0.1.0-dev.cdc78ae

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.
@@ -1,5 +1,5 @@
1
1
  import type { Praxis } from '../core/client.js';
2
- import type { ApplicationAssetsConfig, ApplicationBinding } from '../generated/types.gen.js';
2
+ import type { ApplicationAssetsConfig, ApplicationBinding, ApplicationVersion } from '../generated/types.gen.js';
3
3
  export type AssetsConfig = ApplicationAssetsConfig;
4
4
  export interface PublishInput {
5
5
  /** 应用名;存在同名应用时复用。 */
@@ -20,16 +20,32 @@ export interface PublishInput {
20
20
  bindings?: ApplicationBinding[];
21
21
  /** 静态资源路由配置,原样传递。 */
22
22
  assets_config?: AssetsConfig;
23
+ /** Register an upload session to plan changes; do not upload content or publish. Requires an existing application. */
24
+ dryRun?: boolean;
25
+ /** Preserve current paths omitted from files. Defaults to false (whole-site replacement). */
26
+ keepExisting?: boolean;
23
27
  /** 是否等待部署完成,默认 true。 */
24
28
  wait?: boolean;
25
29
  /** 部署等待上限(毫秒),默认 120000。超时不会撤销部署。 */
26
30
  timeout_ms?: number;
27
31
  }
28
32
  export interface PublishResult {
33
+ /** True when a static-only publication matches the active version. */
34
+ unchanged?: boolean;
35
+ /** Current version when unchanged or planning an existing deployment. */
36
+ version?: ApplicationVersion;
37
+ /** dryRun compares against the active version; counts are distinct content hashes. */
38
+ plan?: {
39
+ added: string[];
40
+ changed: string[];
41
+ removed: string[];
42
+ upload_hashes: number;
43
+ existing_hashes: number;
44
+ };
29
45
  url: string;
30
46
  worker_id: string;
31
- version_id: string;
32
- deployment_id: string;
47
+ version_id: string | null;
48
+ deployment_id: string | null;
33
49
  status: string;
34
50
  created: boolean;
35
51
  /** 本次上传的不同内容 hash 数量。 */
@@ -37,5 +53,9 @@ export interface PublishResult {
37
53
  /** 服务端已存在的不同内容 hash 数量。 */
38
54
  reused: number;
39
55
  }
40
- /** Publish through the public SDK resources, including content-addressed incremental uploads. */
56
+ /** Publish with whole-site replacement by default; keepExisting preserves omitted paths.
57
+ * dryRun registers an idempotent upload session (stores its manifest), but uploads no content and does not publish.
58
+ * The platform does not support gradual rollout. Roll back using workers.deployments.create with
59
+ * strategy: 'percentage', versions: [{ version_id: previousVersionId, percentage: 100 }], while artifacts are retained.
60
+ */
41
61
  export declare function publish(client: Pick<Praxis, 'application'>, input: PublishInput): Promise<PublishResult>;
@@ -8,8 +8,16 @@ function base64(bytes) {
8
8
  }
9
9
  return btoa(chunks.join(''));
10
10
  }
11
- /** Publish through the public SDK resources, including content-addressed incremental uploads. */
11
+ /** Publish with whole-site replacement by default; keepExisting preserves omitted paths.
12
+ * dryRun registers an idempotent upload session (stores its manifest), but uploads no content and does not publish.
13
+ * The platform does not support gradual rollout. Roll back using workers.deployments.create with
14
+ * strategy: 'percentage', versions: [{ version_id: previousVersionId, percentage: 100 }], while artifacts are retained.
15
+ */
12
16
  export async function publish(client, input) {
17
+ const rolloutFields = ['gradual', 'gradualRollout', 'gradual_rollout', 'canary', 'rollout', 'percentage', 'percent', 'traffic', 'traffic_percentage', 'strategy', 'versions'];
18
+ if (Object.keys(input).some(key => rolloutFields.includes(key) || /percentage|percent|canary|gradual|rollout|灰度/i.test(key))) {
19
+ throw new PraxisError({ code: 'INVALID_PARAMS', message: '平台不支持灰度 (gradual rollout is not supported). Roll back with praxis.application.workers.deployments.create({ path: { worker_id }, body: { strategy: "percentage", versions: [{ version_id: previousVersionId, percentage: 100 }] } }); the previous version artifacts must still be retained.' });
20
+ }
13
21
  const timeout = input.timeout_ms ?? 120_000;
14
22
  if (!Number.isFinite(timeout) || timeout < 0)
15
23
  throw new RangeError('timeout_ms must be non-negative and finite');
@@ -21,7 +29,7 @@ export async function publish(client, input) {
21
29
  const files = Object.entries(input.files);
22
30
  if (files.length > 100_000)
23
31
  throw new PraxisError({ code: 'LIMIT_EXCEEDED', message: 'At most 100000 asset paths can be published' });
24
- const manifest = {};
32
+ let manifest = {};
25
33
  const contents = new Map();
26
34
  for (const [path, content] of files) {
27
35
  const normalized = path.startsWith('/') ? path : `/${path}`;
@@ -44,9 +52,51 @@ export async function publish(client, input) {
44
52
  }
45
53
  }
46
54
  const created = !worker;
55
+ if (!worker && input.dryRun)
56
+ throw new PraxisError({ code: 'NOT_FOUND', message: '请先创建应用 (create the application before dryRun)' });
47
57
  worker ??= await workers.create({ body: { name: input.name, subdomain: { enabled: true } } });
48
58
  const path = { worker_id: worker.id };
59
+ let currentVersion;
60
+ let currentDeployment;
61
+ let previous = {};
62
+ if (worker.praxis?.current_deployment_id) {
63
+ currentDeployment = await workers.deployments.retrieve({ path: { ...path, deployment_id: worker.praxis.current_deployment_id } });
64
+ const active = currentDeployment.versions[0];
65
+ if (!active)
66
+ throw new PraxisError({ code: 'INVALID_RESPONSE', message: 'Current deployment has no version' });
67
+ currentVersion = await workers.versions.retrieve({ path: { ...path, version_id: active.version_id } });
68
+ previous = (await workers.versions.assets.retrieveManifest({ path: { ...path, version_id: active.version_id } })).manifest;
69
+ }
70
+ // Keep the stored path order: the service hashes JSON.stringify(manifest), including property order.
71
+ // Reordered input with identical paths must still hash to the active version's manifest_hash.
72
+ manifest = {
73
+ ...Object.fromEntries(Object.entries(previous).filter(([key]) => input.keepExisting || Object.hasOwn(manifest, key))),
74
+ ...manifest,
75
+ };
76
+ if (Object.keys(manifest).length > 100_000)
77
+ throw new PraxisError({ code: 'LIMIT_EXCEEDED', message: 'At most 100000 asset paths can be published' });
78
+ const added = Object.keys(manifest).filter(key => !Object.hasOwn(previous, key));
79
+ const changed = Object.keys(manifest).filter(key => previous[key] && (previous[key].hash !== manifest[key].hash || previous[key].size !== manifest[key].size));
80
+ const removed = Object.keys(previous).filter(key => !Object.hasOwn(manifest, key));
81
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(manifest))));
82
+ const manifestHash = Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
83
+ const staticOnly = !['main', 'bindings', 'compatibility_date', 'compatibility_flags', 'assets_config'].some(key => Object.hasOwn(input, key));
84
+ const unchanged = staticOnly && !!currentVersion && manifestHash === currentVersion.praxis.assets_manifest?.manifest_hash;
85
+ const hashes = new Set(Object.values(manifest).map(entry => entry.hash));
86
+ const currentResult = { url: worker.subdomain.url, worker_id: worker.id, version_id: currentVersion?.id ?? null,
87
+ deployment_id: currentDeployment?.id ?? null, status: currentDeployment?.praxis.status ?? 'draft', created,
88
+ ...(currentVersion ? { version: currentVersion } : {}), unchanged, uploaded: 0, reused: hashes.size };
89
+ if (unchanged && !input.dryRun)
90
+ return currentResult;
91
+ // dryRun intentionally registers the content-addressed session manifest, but never uploads or publishes.
49
92
  const session = await workers.assetsUploadSession.create({ path, body: { manifest } });
93
+ if (input.dryRun) {
94
+ const missing = new Set(session.buckets.flat());
95
+ if ([...missing].some(hash => !hashes.has(hash)))
96
+ throw new PraxisError({ code: 'INVALID_RESPONSE', message: 'Upload session requested an unknown hash' });
97
+ return { ...currentResult, reused: hashes.size - missing.size,
98
+ plan: { added, changed, removed, upload_hashes: missing.size, existing_hashes: hashes.size - missing.size } };
99
+ }
50
100
  let jwt = session.buckets.length ? null : session.jwt;
51
101
  const uploaded = new Set();
52
102
  for (const bucket of session.buckets) {
@@ -112,5 +162,5 @@ export async function publish(client, input) {
112
162
  }
113
163
  return { url: worker.subdomain.url, worker_id: worker.id, version_id: result.version.id,
114
164
  deployment_id: deployment.id, status: deployment.praxis.status, created,
115
- uploaded: uploaded.size, reused: contents.size - uploaded.size };
165
+ uploaded: uploaded.size, reused: hashes.size - uploaded.size };
116
166
  }
@@ -0,0 +1,17 @@
1
+ import type { Transport } from '../core/transport.js';
2
+ export interface CountInput {
3
+ /** Database ID from database list/create; not its display name. */
4
+ database_id: string;
5
+ /** Existing table name or ID from the database table APIs. */
6
+ table: string;
7
+ /** PostgREST query filters, as in databaseSelect; omitted means all rows. */
8
+ filters?: Record<string, string>;
9
+ /** exact counts within budget (default); filtered planned returns null; estimated may return null over budget. Unfiltered counts are exact in every mode. */
10
+ count?: 'exact' | 'planned' | 'estimated';
11
+ }
12
+ export interface CountResult {
13
+ /** Matching total, or null when the server reports an unknown total. */
14
+ count: number | null;
15
+ }
16
+ /** Count matching rows without retrieving row data. */
17
+ export declare function count(transport: Pick<Transport, 'requestWithResponse'>, input: CountInput): Promise<CountResult>;
@@ -0,0 +1,11 @@
1
+ import { databaseHead } from "../generated/sdk.gen.js";
2
+ /** Count matching rows without retrieving row data. */
3
+ export async function count(transport, input) {
4
+ const { response } = await transport.requestWithResponse(databaseHead, {
5
+ path: { database_id: input.database_id, table: input.table },
6
+ query: input.filters,
7
+ headers: { Prefer: `count=${input.count ?? 'exact'}` },
8
+ });
9
+ const total = response.headers.get('Content-Range')?.split('/')[1];
10
+ return { count: total === undefined || total === '*' ? null : Number(total) };
11
+ }
@@ -0,0 +1,24 @@
1
+ import type { Praxis } from '../core/client.js';
2
+ import type { OwnFileOpenAiFile } from '../generated/types.gen.js';
3
+ export interface UploadFileOptions {
4
+ /** user_data for conversation attachments; assistants for assistant files; vision for PNG/JPEG/GIF/WebP. */
5
+ purpose: 'user_data' | 'assistants' | 'vision';
6
+ /** Defaults to File.name, or upload for a Blob. */
7
+ filename?: string;
8
+ /** Fixed part size in bytes, 5 MiB–64 MB; defaults to 50 MiB. */
9
+ partSize?: number;
10
+ /** Concurrent part requests, a positive integer; defaults to 3. */
11
+ concurrency?: number;
12
+ /** Aborts outstanding requests and cancels an unfinished Upload. */
13
+ signal?: AbortSignal;
14
+ /** Acknowledged byte fraction, 0–1. A value of 1 does not imply processing has finished. */
15
+ onProgress?: (fraction: number) => void;
16
+ }
17
+ export type UploadedFile = OwnFileOpenAiFile;
18
+ /** Upload a nonempty file up to 2 GiB, returning only after processing reaches active.
19
+ * Files up to 32 MiB use one request; larger files use fixed-size Uploads parts.
20
+ * Parts and complete each retry transient failures at most five times with exponential backoff.
21
+ * A terminal failure or abort cancels the Upload. Completed files cannot be cancelled;
22
+ * if abort occurs during processing, delete the resulting file explicitly if no longer needed.
23
+ */
24
+ export declare function uploadFile(client: Pick<Praxis, 'file'>, file: Blob | File, options: UploadFileOptions): Promise<UploadedFile>;
@@ -0,0 +1,113 @@
1
+ import { PraxisError, PraxisConnectionError, PraxisTimeoutError } from "../core/errors.js";
2
+ import { sleep } from "../core/retry.js";
3
+ const MAX_RETRIES = 5;
4
+ function retryable(error) {
5
+ return error instanceof PraxisConnectionError || error instanceof PraxisTimeoutError
6
+ || error instanceof SyntaxError
7
+ || (error instanceof PraxisError && (error.status === 408 || error.status === 429 || (error.status !== undefined && error.status >= 500 && error.status <= 599)));
8
+ }
9
+ async function retry(signal, operation) {
10
+ for (let used = 0;; used++) {
11
+ signal.throwIfAborted();
12
+ try {
13
+ return await operation();
14
+ }
15
+ catch (error) {
16
+ signal.throwIfAborted();
17
+ if (used >= MAX_RETRIES || !retryable(error))
18
+ throw error;
19
+ await sleep(500 * 2 ** used, signal);
20
+ }
21
+ }
22
+ }
23
+ /** Upload a nonempty file up to 2 GiB, returning only after processing reaches active.
24
+ * Files up to 32 MiB use one request; larger files use fixed-size Uploads parts.
25
+ * Parts and complete each retry transient failures at most five times with exponential backoff.
26
+ * A terminal failure or abort cancels the Upload. Completed files cannot be cancelled;
27
+ * if abort occurs during processing, delete the resulting file explicitly if no longer needed.
28
+ */
29
+ export async function uploadFile(client, file, options) {
30
+ options.signal?.throwIfAborted();
31
+ if (file.size < 1 || file.size > 2 * 1024 ** 3)
32
+ throw new RangeError('File size must be 1 byte through 2 GiB');
33
+ const partSize = options.partSize ?? 50 * 1024 ** 2;
34
+ const concurrency = options.concurrency ?? 3;
35
+ if (!Number.isSafeInteger(partSize) || partSize < 5 * 1024 ** 2 || partSize > 64_000_000)
36
+ throw new RangeError('partSize must be an integer from 5242880 through 64000000');
37
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1)
38
+ throw new RangeError('concurrency must be a positive integer');
39
+ const filename = options.filename ?? (typeof File !== 'undefined' && file instanceof File ? file.name : 'upload');
40
+ const work = new AbortController();
41
+ const abort = () => work.abort(options.signal?.reason);
42
+ options.signal?.addEventListener('abort', abort, { once: true });
43
+ const signal = work.signal;
44
+ const request = { signal, maxRetries: 0, timeoutMs: 120_000 };
45
+ let uploadId;
46
+ const active = async (initial) => {
47
+ let result = initial;
48
+ for (let polls = 0;; polls++) {
49
+ signal.throwIfAborted();
50
+ if (result.state === 'active')
51
+ return result;
52
+ if (result.state !== 'processing')
53
+ throw new PraxisError({ code: 'FILE_PROCESSING_FAILED', message: `File ${result.id} is not active (${result.state})` });
54
+ await sleep(Math.min(5000, 1000 * 2 ** polls), signal);
55
+ result = await retry(signal, () => client.file.files.retrieve({ path: { file_id: result.id }, ...request }));
56
+ }
57
+ };
58
+ try {
59
+ signal.throwIfAborted();
60
+ options.onProgress?.(0);
61
+ if (file.size <= 32 * 1024 ** 2) {
62
+ const result = await client.file.files.create({ body: { file: new File([file], filename, { type: file.type }), purpose: options.purpose }, ...request });
63
+ options.onProgress?.(1);
64
+ return await active(result);
65
+ }
66
+ const upload = await client.file.uploads.create({ body: { bytes: file.size, filename, mime_type: file.type || 'application/octet-stream', purpose: options.purpose, part_size: partSize }, ...request });
67
+ uploadId = upload.id;
68
+ signal.throwIfAborted();
69
+ const path = { upload_id: uploadId };
70
+ const count = Math.ceil(file.size / partSize);
71
+ const ids = new Array(count);
72
+ let cursor = 0, confirmed = 0;
73
+ const worker = async () => {
74
+ while (cursor < count) {
75
+ signal.throwIfAborted();
76
+ const index = cursor++;
77
+ const chunk = file.slice(index * partSize, Math.min(file.size, (index + 1) * partSize));
78
+ const part = await retry(signal, () => client.file.uploads.parts.create({ path, query: { part_number: index + 1 }, body: { data: new File([chunk], filename) }, ...request }));
79
+ ids[index] = part.id;
80
+ confirmed += chunk.size;
81
+ options.onProgress?.(confirmed / file.size);
82
+ }
83
+ };
84
+ const workers = Array.from({ length: Math.min(concurrency, count) }, worker);
85
+ try {
86
+ await Promise.all(workers);
87
+ }
88
+ catch (error) {
89
+ work.abort();
90
+ await Promise.allSettled(workers);
91
+ throw error;
92
+ }
93
+ const result = await retry(signal, () => client.file.uploads.complete({ path, body: { part_ids: ids }, ...request }));
94
+ if (result.error)
95
+ throw new PraxisError({ code: result.error.code, message: result.error.message });
96
+ if (result.status !== 'completed' || !result.file)
97
+ throw new PraxisError({ code: 'INVALID_RESPONSE', message: 'Complete did not return a completed Upload with a file' });
98
+ return await active(result.file);
99
+ }
100
+ catch (error) {
101
+ work.abort();
102
+ if (uploadId) {
103
+ // Cleanup uses its own timeout and must not inherit the aborted work signal.
104
+ await client.file.uploads.cancel({ path: { upload_id: uploadId }, maxRetries: 0, timeoutMs: 10_000 }).catch(() => { });
105
+ }
106
+ if (options.signal?.aborted)
107
+ throw options.signal.reason;
108
+ throw error;
109
+ }
110
+ finally {
111
+ options.signal?.removeEventListener('abort', abort);
112
+ }
113
+ }
package/dist/index.d.ts CHANGED
@@ -7,3 +7,7 @@ export { parseSSE } from './core/sse.js';
7
7
  export type { SSEEvent, SSEOptions } from './core/sse.js';
8
8
  export { publish } from './helpers/application-publish.js';
9
9
  export type { PublishInput, PublishResult, AssetsConfig } from './helpers/application-publish.js';
10
+ export { count } from './helpers/database-count.js';
11
+ export type { CountInput, CountResult } from './helpers/database-count.js';
12
+ export { uploadFile } from './helpers/file-upload.js';
13
+ export type { UploadFileOptions, UploadedFile } from './helpers/file-upload.js';
package/dist/index.js CHANGED
@@ -3,3 +3,5 @@ export { PraxisError, PraxisConnectionError, PraxisTimeoutError } from "./core/e
3
3
  export { Page } from "./core/pagination.js";
4
4
  export { parseSSE } from "./core/sse.js";
5
5
  export { publish } from "./helpers/application-publish.js";
6
+ export { count } from "./helpers/database-count.js";
7
+ export { uploadFile } from "./helpers/file-upload.js";