@moloodev/bundle-sdk 0.0.0-stage → 0.1.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/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@moloodev/bundle-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./idb": "./src/idb.ts"
8
+ },
9
+ "dependencies": {
10
+ "@moloodev/platform": "0.1.0"
11
+ },
12
+ "devDependencies": {
13
+ "@types/node": "^24.13.3",
14
+ "tsx": "^4.23.13",
15
+ "typescript": "^7.0.2",
16
+ "@moloodev/typescript-config": "0.1.0"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "files": [
22
+ "src",
23
+ "!src/**/*.test.ts",
24
+ "!src/**/*.test.tsx"
25
+ ],
26
+ "scripts": {
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "tsx --test 'src/**/*.test.ts'"
29
+ }
6
30
  }
package/src/client.ts ADDED
@@ -0,0 +1,294 @@
1
+ import { decodeJson, digestJson, encodeJson, sha256Hex } from "./digest";
2
+ import { ConsumeError } from "./error";
3
+ import {
4
+ consumeBytes,
5
+ consumeGet,
6
+ DEFAULT_API_ORIGIN,
7
+ type FetchLike,
8
+ } from "./http";
9
+ import {
10
+ fileMetaKey,
11
+ manifestKey,
12
+ memberKey,
13
+ MemoryStore,
14
+ type BundleStore,
15
+ } from "./store";
16
+ import type {
17
+ BundleSnapshot,
18
+ ConsumeLatest,
19
+ ConsumeManifest,
20
+ ConsumeMember,
21
+ ConsumeMemberMeta,
22
+ LoadedMember,
23
+ LoadOptions,
24
+ LoadProgress,
25
+ } from "./types";
26
+
27
+ export type BundleClientOptions = {
28
+ bundleId: string;
29
+ baseUrl?: string;
30
+ apiKey?: string;
31
+ store?: BundleStore;
32
+ fetch?: FetchLike;
33
+ sleep?: (ms: number) => Promise<void>;
34
+ };
35
+
36
+ export type BundleClient = {
37
+ load(options?: LoadOptions): Promise<BundleSnapshot>;
38
+ read(revision?: number): Promise<BundleSnapshot | null>;
39
+ };
40
+
41
+ type FileMeta = {
42
+ fileName: string;
43
+ contentType: string;
44
+ size: number;
45
+ };
46
+
47
+ const MEMBER_CONCURRENCY = 4;
48
+
49
+ function joinUrl(base: string, path: string): string {
50
+ return `${base.replace(/\/$/, "")}${path}`;
51
+ }
52
+
53
+ async function mapPool<T, R>(
54
+ items: T[],
55
+ concurrency: number,
56
+ fn: (item: T) => Promise<R>,
57
+ ): Promise<R[]> {
58
+ if (items.length === 0) return [];
59
+ const results = new Array<R>(items.length);
60
+ let next = 0;
61
+ async function worker() {
62
+ for (;;) {
63
+ const index = next;
64
+ next += 1;
65
+ if (index >= items.length) return;
66
+ results[index] = await fn(items[index]!);
67
+ }
68
+ }
69
+ await Promise.all(
70
+ Array.from({ length: Math.min(concurrency, items.length) }, () => worker()),
71
+ );
72
+ return results;
73
+ }
74
+
75
+ export function createBundleClient(options: BundleClientOptions): BundleClient {
76
+ const { bundleId, apiKey, sleep } = options;
77
+ const baseUrl = options.baseUrl ?? DEFAULT_API_ORIGIN;
78
+ const store = options.store ?? new MemoryStore();
79
+ const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
80
+ const prefix = `/v1/bundles/${encodeURIComponent(bundleId)}`;
81
+ const manifestPrefix = `m:${bundleId}:`;
82
+
83
+ function request<T>(url: string, signal?: AbortSignal) {
84
+ return consumeGet<T>({
85
+ fetch: fetchFn,
86
+ url,
87
+ apiKey,
88
+ signal,
89
+ sleep,
90
+ });
91
+ }
92
+
93
+ async function readMember(meta: ConsumeMemberMeta): Promise<LoadedMember | null> {
94
+ const bytes = await store.get(memberKey(meta.assetId, meta.revision, meta.digest));
95
+ if (!bytes) return null;
96
+ if (meta.storage === "blob") {
97
+ if ((await sha256Hex(bytes)) !== meta.digest) return null;
98
+ const rawMeta = await store.get(fileMetaKey(meta.assetId, meta.revision, meta.digest));
99
+ if (!rawMeta) return null;
100
+ try {
101
+ const file = decodeJson<FileMeta>(rawMeta);
102
+ return { ...meta, file: { ...file, bytes } };
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+ try {
108
+ const payload = decodeJson<Record<string, unknown>>(bytes);
109
+ if ((await digestJson(payload)) !== meta.digest) return null;
110
+ return { ...meta, payload };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ async function writeMember(member: LoadedMember): Promise<void> {
117
+ const key = memberKey(member.assetId, member.revision, member.digest);
118
+ if (member.storage === "blob") {
119
+ if (!member.file) return;
120
+ await store.set(key, member.file.bytes);
121
+ await store.set(
122
+ fileMetaKey(member.assetId, member.revision, member.digest),
123
+ encodeJson({
124
+ fileName: member.file.fileName,
125
+ contentType: member.file.contentType,
126
+ size: member.file.size,
127
+ } satisfies FileMeta),
128
+ );
129
+ return;
130
+ }
131
+ if (!member.payload) return;
132
+ await store.set(key, encodeJson(member.payload));
133
+ }
134
+
135
+ async function readRevision(revision: number): Promise<BundleSnapshot | null> {
136
+ const raw = await store.get(manifestKey(bundleId, revision));
137
+ if (!raw) return null;
138
+ let manifest: ConsumeManifest;
139
+ try {
140
+ manifest = decodeJson<ConsumeManifest>(raw);
141
+ } catch {
142
+ return null;
143
+ }
144
+ const members: LoadedMember[] = [];
145
+ for (const meta of manifest.members) {
146
+ const loaded = await readMember(meta);
147
+ if (!loaded) return null;
148
+ members.push(loaded);
149
+ }
150
+ return { ...manifest, members };
151
+ }
152
+
153
+ async function listedRevisions(): Promise<number[]> {
154
+ const keys = await store.list(manifestPrefix);
155
+ return keys
156
+ .map((key) => Number(key.slice(manifestPrefix.length)))
157
+ .filter((revision) => Number.isInteger(revision) && revision > 0)
158
+ .sort((left, right) => right - left);
159
+ }
160
+
161
+ async function read(revision?: number): Promise<BundleSnapshot | null> {
162
+ if (revision != null) return readRevision(revision);
163
+ for (const candidate of await listedRevisions()) {
164
+ const snapshot = await readRevision(candidate);
165
+ if (snapshot) return snapshot;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ async function downloadMember(
171
+ revision: number,
172
+ meta: ConsumeMemberMeta,
173
+ signal: AbortSignal | undefined,
174
+ onProgress?: (progress: LoadProgress) => void,
175
+ ): Promise<LoadedMember> {
176
+ let lastError: ConsumeError | undefined;
177
+ for (let attempt = 0; attempt < 2; attempt++) {
178
+ const member = await request<ConsumeMember>(
179
+ joinUrl(
180
+ baseUrl,
181
+ `${prefix}/revisions/${revision}/assets/${encodeURIComponent(meta.assetId)}`,
182
+ ),
183
+ signal,
184
+ );
185
+ if (meta.storage === "blob") {
186
+ if (!member.file?.url) {
187
+ lastError = new ConsumeError("integrity");
188
+ continue;
189
+ }
190
+ onProgress?.({
191
+ phase: "file",
192
+ done: 0,
193
+ total: 1,
194
+ assetId: meta.assetId,
195
+ bytesTotal: member.file.size,
196
+ });
197
+ const bytes = await consumeBytes({
198
+ fetch: fetchFn,
199
+ url: member.file.url,
200
+ signal,
201
+ sleep,
202
+ });
203
+ if ((await sha256Hex(bytes)) !== meta.digest) {
204
+ lastError = new ConsumeError("integrity");
205
+ continue;
206
+ }
207
+ return {
208
+ ...meta,
209
+ file: {
210
+ bytes,
211
+ fileName: member.file.fileName,
212
+ contentType: member.file.contentType,
213
+ size: member.file.size,
214
+ },
215
+ };
216
+ }
217
+ if (!member.payload || (await digestJson(member.payload)) !== meta.digest) {
218
+ lastError = new ConsumeError("integrity");
219
+ continue;
220
+ }
221
+ return { ...meta, payload: member.payload };
222
+ }
223
+ throw lastError ?? new ConsumeError("integrity");
224
+ }
225
+
226
+ async function pull(load: LoadOptions | undefined): Promise<BundleSnapshot> {
227
+ const signal = load?.signal;
228
+ let revision = load?.revision;
229
+ if (revision == null) {
230
+ load?.onProgress?.({ phase: "latest", done: 0, total: 1 });
231
+ const latest = await request<ConsumeLatest>(
232
+ joinUrl(baseUrl, `${prefix}/latest`),
233
+ signal,
234
+ );
235
+ revision = latest.revision;
236
+ load?.onProgress?.({ phase: "latest", done: 1, total: 1 });
237
+ }
238
+ load?.onProgress?.({ phase: "manifest", done: 0, total: 1 });
239
+ const manifest = await request<ConsumeManifest>(
240
+ joinUrl(baseUrl, `${prefix}/revisions/${revision}`),
241
+ signal,
242
+ );
243
+ load?.onProgress?.({ phase: "manifest", done: 1, total: 1 });
244
+
245
+ const total = manifest.members.length;
246
+ let done = 0;
247
+ const members = await mapPool(manifest.members, MEMBER_CONCURRENCY, async (meta) => {
248
+ let loaded = load?.recover ? null : await readMember(meta);
249
+ if (!loaded) {
250
+ loaded = await downloadMember(manifest.revision, meta, signal, load?.onProgress);
251
+ await writeMember(loaded);
252
+ }
253
+ done += 1;
254
+ load?.onProgress?.({
255
+ phase: "member",
256
+ done,
257
+ total,
258
+ assetId: meta.assetId,
259
+ });
260
+ return loaded;
261
+ });
262
+
263
+ await store.set(
264
+ manifestKey(bundleId, manifest.revision),
265
+ encodeJson({
266
+ workspaceId: manifest.workspaceId,
267
+ bundleId: manifest.bundleId,
268
+ revision: manifest.revision,
269
+ name: manifest.name,
270
+ message: manifest.message,
271
+ createdAt: manifest.createdAt,
272
+ members: manifest.members,
273
+ } satisfies ConsumeManifest),
274
+ );
275
+ return { ...manifest, members };
276
+ }
277
+
278
+ return {
279
+ async load(load) {
280
+ try {
281
+ return await pull(load);
282
+ } catch (error) {
283
+ if (error instanceof ConsumeError && error.code === "offline") {
284
+ const local =
285
+ (load?.revision != null ? await readRevision(load.revision) : null) ??
286
+ (await read());
287
+ if (local) return local;
288
+ }
289
+ throw error;
290
+ }
291
+ },
292
+ read,
293
+ };
294
+ }
package/src/digest.ts ADDED
@@ -0,0 +1,20 @@
1
+ const encoder = new TextEncoder();
2
+
3
+ export function encodeJson(value: unknown): Uint8Array {
4
+ return encoder.encode(JSON.stringify(value));
5
+ }
6
+
7
+ export function decodeJson<T>(bytes: Uint8Array): T {
8
+ return JSON.parse(new TextDecoder().decode(bytes)) as T;
9
+ }
10
+
11
+ export async function sha256Hex(data: Uint8Array): Promise<string> {
12
+ const digest = await crypto.subtle.digest("SHA-256", data as BufferSource);
13
+ return Array.from(new Uint8Array(digest), (byte) =>
14
+ byte.toString(16).padStart(2, "0"),
15
+ ).join("");
16
+ }
17
+
18
+ export async function digestJson(value: unknown): Promise<string> {
19
+ return sha256Hex(encodeJson(value));
20
+ }
package/src/error.ts ADDED
@@ -0,0 +1,24 @@
1
+ export type ConsumeErrorCode =
2
+ | "not_found"
3
+ | "unauthorized"
4
+ | "forbidden"
5
+ | "offline"
6
+ | "integrity"
7
+ | "aborted"
8
+ | "http";
9
+
10
+ export class ConsumeError extends Error {
11
+ readonly code: ConsumeErrorCode;
12
+ readonly status?: number;
13
+
14
+ constructor(code: ConsumeErrorCode, message?: string, status?: number) {
15
+ super(message ?? code);
16
+ this.name = "ConsumeError";
17
+ this.code = code;
18
+ this.status = status;
19
+ }
20
+ }
21
+
22
+ export function isConsumeError(error: unknown): error is ConsumeError {
23
+ return error instanceof ConsumeError;
24
+ }
package/src/http.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { getApiOrigin } from "@moloodev/platform";
2
+ import { ConsumeError, type ConsumeErrorCode } from "./error";
3
+
4
+ /** Production gateway. Callers pass `baseUrl` when they need another origin. */
5
+ export const DEFAULT_API_ORIGIN = getApiOrigin("production");
6
+
7
+ export type FetchLike = typeof fetch;
8
+
9
+ export type ConsumeRequestOptions = {
10
+ fetch: FetchLike;
11
+ url: string;
12
+ apiKey?: string;
13
+ signal?: AbortSignal;
14
+ sleep?: (ms: number) => Promise<void>;
15
+ };
16
+
17
+ function defaultSleep(ms: number): Promise<void> {
18
+ return new Promise((resolve) => {
19
+ setTimeout(resolve, ms);
20
+ });
21
+ }
22
+
23
+ function isAbortError(error: unknown): boolean {
24
+ return Boolean(error && typeof error === "object" && "name" in error && error.name === "AbortError");
25
+ }
26
+
27
+ function codeForStatus(status: number): ConsumeErrorCode {
28
+ if (status === 401) return "unauthorized";
29
+ if (status === 403) return "forbidden";
30
+ if (status === 404) return "not_found";
31
+ return "http";
32
+ }
33
+
34
+ function retryDelayMs(response: Response, attempt: number): number {
35
+ const header = response.headers.get("retry-after");
36
+ if (header) {
37
+ const seconds = Number(header);
38
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
39
+ }
40
+ return 200 * 2 ** attempt;
41
+ }
42
+
43
+ async function request(options: ConsumeRequestOptions): Promise<Response> {
44
+ const headers = new Headers();
45
+ if (options.apiKey) headers.set("x-api-key", options.apiKey);
46
+ const sleep = options.sleep ?? defaultSleep;
47
+
48
+ for (let attempt = 0; attempt < 3; attempt++) {
49
+ let response: Response;
50
+ try {
51
+ response = await options.fetch(options.url, {
52
+ headers,
53
+ signal: options.signal,
54
+ });
55
+ } catch (error) {
56
+ if (isAbortError(error) || options.signal?.aborted) {
57
+ throw new ConsumeError("aborted");
58
+ }
59
+ throw new ConsumeError(
60
+ "offline",
61
+ error instanceof Error ? error.message : "offline",
62
+ );
63
+ }
64
+
65
+ if (response.ok) return response;
66
+ if (response.status === 429 || response.status >= 500) {
67
+ if (attempt === 2) {
68
+ throw new ConsumeError(codeForStatus(response.status), undefined, response.status);
69
+ }
70
+ await sleep(retryDelayMs(response, attempt));
71
+ continue;
72
+ }
73
+ throw new ConsumeError(codeForStatus(response.status), undefined, response.status);
74
+ }
75
+
76
+ throw new ConsumeError("http");
77
+ }
78
+
79
+ export async function consumeGet<T>(options: ConsumeRequestOptions): Promise<T> {
80
+ const response = await request(options);
81
+ const body = (await response.json()) as { data?: T };
82
+ if (!body || typeof body !== "object" || !("data" in body)) {
83
+ throw new ConsumeError("http", "invalid response", response.status);
84
+ }
85
+ return body.data as T;
86
+ }
87
+
88
+ export async function consumeBytes(options: ConsumeRequestOptions): Promise<Uint8Array> {
89
+ const response = await request(options);
90
+ return new Uint8Array(await response.arrayBuffer());
91
+ }
package/src/idb.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { BundleStore } from "./store";
2
+
3
+ const STORE = "entries";
4
+
5
+ export type IdbStoreOptions = {
6
+ name?: string;
7
+ indexedDB?: IDBFactory;
8
+ };
9
+
10
+ function requestValue<T>(request: IDBRequest<T>): Promise<T> {
11
+ return new Promise((resolve, reject) => {
12
+ request.onsuccess = () => resolve(request.result);
13
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
14
+ });
15
+ }
16
+
17
+ function openDatabase(factory: IDBFactory, name: string): Promise<IDBDatabase> {
18
+ return new Promise((resolve, reject) => {
19
+ const request = factory.open(name, 1);
20
+ request.onupgradeneeded = () => {
21
+ const db = request.result;
22
+ if (!db.objectStoreNames.contains(STORE)) {
23
+ db.createObjectStore(STORE);
24
+ }
25
+ };
26
+ request.onsuccess = () => resolve(request.result);
27
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB open failed"));
28
+ });
29
+ }
30
+
31
+ function asBytes(value: unknown): Uint8Array | null {
32
+ if (value == null) return null;
33
+ if (value instanceof Uint8Array) return new Uint8Array(value);
34
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
35
+ return null;
36
+ }
37
+
38
+ export function createIdbStore(options: IdbStoreOptions = {}): BundleStore {
39
+ const factory = options.indexedDB ?? globalThis.indexedDB;
40
+ if (!factory) {
41
+ throw new Error("IndexedDB is not available");
42
+ }
43
+ const ready = openDatabase(factory, options.name ?? "moloo-bundle");
44
+
45
+ return {
46
+ async get(key) {
47
+ const db = await ready;
48
+ const value = await requestValue(
49
+ db.transaction(STORE, "readonly").objectStore(STORE).get(key),
50
+ );
51
+ return asBytes(value);
52
+ },
53
+ async set(key, value) {
54
+ const db = await ready;
55
+ await requestValue(
56
+ db.transaction(STORE, "readwrite").objectStore(STORE).put(new Uint8Array(value), key),
57
+ );
58
+ },
59
+ async delete(key) {
60
+ const db = await ready;
61
+ await requestValue(db.transaction(STORE, "readwrite").objectStore(STORE).delete(key));
62
+ },
63
+ async list(prefix) {
64
+ const db = await ready;
65
+ const keys = await requestValue(
66
+ db.transaction(STORE, "readonly").objectStore(STORE).getAllKeys(),
67
+ );
68
+ return keys.map(String).filter((key) => key.startsWith(prefix)).sort();
69
+ },
70
+ };
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export {
2
+ createBundleClient,
3
+ type BundleClient,
4
+ type BundleClientOptions,
5
+ } from "./client";
6
+ export { ConsumeError, isConsumeError, type ConsumeErrorCode } from "./error";
7
+ export { DEFAULT_API_ORIGIN } from "./http";
8
+ export { MemoryStore, type BundleStore } from "./store";
9
+ export type {
10
+ BundleSnapshot,
11
+ ConsumeFile,
12
+ ConsumeLatest,
13
+ ConsumeManifest,
14
+ ConsumeMember,
15
+ ConsumeMemberMeta,
16
+ LoadedFile,
17
+ LoadedMember,
18
+ LoadOptions,
19
+ LoadProgress,
20
+ } from "./types";
package/src/store.ts ADDED
@@ -0,0 +1,47 @@
1
+ export type BundleStore = {
2
+ get(key: string): Promise<Uint8Array | null>;
3
+ set(key: string, value: Uint8Array): Promise<void>;
4
+ delete(key: string): Promise<void>;
5
+ list(prefix: string): Promise<string[]>;
6
+ };
7
+
8
+ export function manifestKey(bundleId: string, revision: number): string {
9
+ return `m:${bundleId}:${revision}`;
10
+ }
11
+
12
+ export function memberKey(
13
+ assetId: string,
14
+ revision: number,
15
+ digest: string,
16
+ ): string {
17
+ return `a:${assetId}:${revision}:${digest}`;
18
+ }
19
+
20
+ export function fileMetaKey(
21
+ assetId: string,
22
+ revision: number,
23
+ digest: string,
24
+ ): string {
25
+ return `f:${assetId}:${revision}:${digest}`;
26
+ }
27
+
28
+ export class MemoryStore implements BundleStore {
29
+ #data = new Map<string, Uint8Array>();
30
+
31
+ async get(key: string): Promise<Uint8Array | null> {
32
+ const value = this.#data.get(key);
33
+ return value ? new Uint8Array(value) : null;
34
+ }
35
+
36
+ async set(key: string, value: Uint8Array): Promise<void> {
37
+ this.#data.set(key, new Uint8Array(value));
38
+ }
39
+
40
+ async delete(key: string): Promise<void> {
41
+ this.#data.delete(key);
42
+ }
43
+
44
+ async list(prefix: string): Promise<string[]> {
45
+ return [...this.#data.keys()].filter((key) => key.startsWith(prefix)).sort();
46
+ }
47
+ }
package/src/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ export type ConsumeMemberMeta = {
2
+ assetId: string;
3
+ type: string;
4
+ revision: number;
5
+ inclusion: string;
6
+ storage: "document" | "blob";
7
+ size: number;
8
+ digest: string;
9
+ };
10
+
11
+ export type ConsumeManifest = {
12
+ workspaceId: string;
13
+ bundleId: string;
14
+ revision: number;
15
+ name: string;
16
+ message: string;
17
+ createdAt: string;
18
+ members: ConsumeMemberMeta[];
19
+ };
20
+
21
+ export type ConsumeLatest = {
22
+ workspaceId: string;
23
+ bundleId: string;
24
+ revision: number;
25
+ };
26
+
27
+ export type ConsumeFile = {
28
+ url: string;
29
+ variants: Record<string, string> | null;
30
+ fileName: string;
31
+ contentType: string;
32
+ size: number;
33
+ };
34
+
35
+ export type ConsumeMember = ConsumeMemberMeta & {
36
+ payload?: Record<string, unknown>;
37
+ file?: ConsumeFile;
38
+ };
39
+
40
+ export type LoadedFile = {
41
+ bytes: Uint8Array;
42
+ fileName: string;
43
+ contentType: string;
44
+ size: number;
45
+ };
46
+
47
+ export type LoadedMember = ConsumeMemberMeta & {
48
+ payload?: Record<string, unknown>;
49
+ file?: LoadedFile;
50
+ };
51
+
52
+ export type BundleSnapshot = Omit<ConsumeManifest, "members"> & {
53
+ members: LoadedMember[];
54
+ };
55
+
56
+ export type LoadProgress = {
57
+ phase: "latest" | "manifest" | "member" | "file";
58
+ done: number;
59
+ total: number;
60
+ bytesDone?: number;
61
+ bytesTotal?: number;
62
+ assetId?: string;
63
+ };
64
+
65
+ export type LoadOptions = {
66
+ revision?: number;
67
+ recover?: boolean;
68
+ signal?: AbortSignal;
69
+ onProgress?: (progress: LoadProgress) => void;
70
+ };
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.