busa-sdk 0.98.0 → 0.99.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/dist/airapp.d.ts CHANGED
@@ -1,4 +1,307 @@
1
- import { t as BusabaseClient } from "./client-DtzuVlPK.js";
1
+ import { l as BusabaseAirAppRuntimeReport } from "./airapp-node-D2GcMwde.js";
2
+ import { t as BusabaseClient, zt as RecordVO } from "./client-BUzSZV3L.js";
3
+ //#region src/airapp-browser-runtime.d.ts
4
+ /** What the probe resolved to. */
5
+ interface AirAppRuntimeInfo {
6
+ /**
7
+ * The engine name the server reported, verbatim (`"browser"`, `"local"`,
8
+ * `"standalone"`, or one this SDK has never heard of), or `"unknown"` when the
9
+ * probe did not get an answer.
10
+ */
11
+ runtime: string;
12
+ /** Busabase is hosting this app. Taken from the server's boolean, never derived here. */
13
+ hosted: boolean;
14
+ /**
15
+ * A standalone run is proxying `/api/v1` to a configured Busabase base URL.
16
+ * A separate axis from `hosted` — "where am I" is not "do I have credentials".
17
+ */
18
+ devProxy: boolean;
19
+ /** `false` when the probe did not answer — the `unknown` outcome. */
20
+ determined: boolean;
21
+ }
22
+ /** Relative on purpose; see the module comment. */
23
+ declare const AIRAPP_RUNTIME_PATH = "__airapp/runtime";
24
+ /** How long the probe waits before resolving to `unknown`. */
25
+ declare const AIRAPP_RUNTIME_TIMEOUT_MS = 3000;
26
+ /** The `unknown` outcome: the probe did not get a usable answer. */
27
+ declare const AIRAPP_RUNTIME_UNKNOWN: Readonly<AirAppRuntimeInfo>;
28
+ /**
29
+ * Turn a `__airapp/runtime` body into an {@link AirAppRuntimeInfo}. Anything that
30
+ * is not an object with a string `runtime` is `unknown`.
31
+ *
32
+ * `hosted` is `body.hosted === true` — the server's own answer, read as-is.
33
+ */
34
+ declare function normalizeAirAppRuntime(body: Partial<BusabaseAirAppRuntimeReport> | unknown): AirAppRuntimeInfo;
35
+ interface DetectAirAppRuntimeOptions {
36
+ /**
37
+ * Injected `fetch`. When set, the probe always runs fresh and nothing is
38
+ * cached — it is a different question from "what did this page's probe say".
39
+ */
40
+ fetch?: typeof globalThis.fetch;
41
+ /** Defaults to {@link AIRAPP_RUNTIME_TIMEOUT_MS}. */
42
+ timeoutMs?: number;
43
+ }
44
+ /**
45
+ * Ask the app's own server where it is running. Never rejects: a failed,
46
+ * non-JSON, or slow probe resolves to {@link AIRAPP_RUNTIME_UNKNOWN}.
47
+ *
48
+ * Called without an injected `fetch`, the first call probes and every later call
49
+ * on the page shares that answer, so the connect gate, the provider, and the
50
+ * settings panel can each ask without three round trips.
51
+ *
52
+ * @example
53
+ * ```js
54
+ * // app/js/runtime.js
55
+ * import { detectAirAppRuntime, shouldUseLocalAirAppGateway } from "../vendor/busabase-airapp.js";
56
+ *
57
+ * export const runtime = await detectAirAppRuntime();
58
+ * export const shouldUseLocalGateway = () => shouldUseLocalAirAppGateway(runtime);
59
+ * ```
60
+ */
61
+ declare function detectAirAppRuntime(options?: DetectAirAppRuntimeOptions): Promise<AirAppRuntimeInfo>;
62
+ /**
63
+ * Whether the app's local `/auth/*` connect gate applies — the predicate for
64
+ * `createAirAppConnectGate({ shouldGate })`.
65
+ *
66
+ * `!hosted`, so `unknown` shows the gate: an unnecessary gate is closed by the
67
+ * operator, a missing one strands them. Where the app runs never decides whether
68
+ * a write merges — Busabase decides that from the person's own permission.
69
+ */
70
+ declare const shouldUseLocalAirAppGateway: (runtime: AirAppRuntimeInfo) => boolean;
71
+ //#endregion
72
+ //#region src/airapp-write-outcome.d.ts
73
+ /**
74
+ * Did my write land, or is it waiting for someone? — one answer for every write.
75
+ *
76
+ * A person's write from an AirApp leaves `autoMerge` unset, so Busabase decides
77
+ * from that person's own permission: with write access the change lands now,
78
+ * without it the same change becomes a ChangeRequest for someone who has it.
79
+ * Both are correct outcomes. The app's job is to say which one happened instead
80
+ * of assuming either, and the response is the only place that says.
81
+ *
82
+ * It says it in three shapes, which is why this module exists:
83
+ *
84
+ * - `bases.createChangeRequest` and `records.changeRequest` return either the
85
+ * materialized record (`materialized: true`) or the pending ChangeRequest
86
+ * (`materialized: false`).
87
+ * - The bulk endpoints (`createBulkChangeRequest`,
88
+ * `createBulkUpdateChangeRequest`) return a ChangeRequest whose `status` is
89
+ * `"merged"` when it landed and `"in_review"` when it is waiting.
90
+ * - An older server may answer with a bare record (`status: "active"`).
91
+ *
92
+ * 48 templates each carried `isQueued = (r) => r?.materialized === false ||
93
+ * r?.status === "in_review"` and their own copy of the user-facing sentence. One
94
+ * definition here means the next change to either happens once.
95
+ */
96
+ /** Whether a write landed (`saved`) or is waiting for someone with write access (`queued`). */
97
+ type AirAppWriteStatus = "saved" | "queued";
98
+ /** Every write response, normalized. */
99
+ interface AirAppWriteOutcome {
100
+ status: AirAppWriteStatus;
101
+ /**
102
+ * The record the write created or changed, when the response names one: the
103
+ * materialized record's id, or for a ChangeRequest its single record target.
104
+ * Absent for a queued create (the record does not exist yet) and for a bulk
105
+ * write touching several records — see `recordIds`.
106
+ */
107
+ recordId?: string;
108
+ /** Every record a merged bulk write produced or changed, in operation order. */
109
+ recordIds?: string[];
110
+ /** The ChangeRequest the write produced, when the response carries one. */
111
+ changeRequestId?: string;
112
+ }
113
+ /** The fields of a write response this module reads. Every write response satisfies it. */
114
+ interface AirAppWriteResponseLike {
115
+ id?: string;
116
+ status?: string;
117
+ materialized?: boolean;
118
+ /** Present on a ChangeRequest; distinguishes one from a record. */
119
+ operations?: readonly {
120
+ mergedRecordId?: string | null;
121
+ targetRecordId?: string | null;
122
+ }[];
123
+ primaryOperation?: {
124
+ mergedRecordId?: string | null;
125
+ targetRecordId?: string | null;
126
+ } | null;
127
+ /** Rides along on a merged create for internal callers; usually stripped by the server. */
128
+ changeRequestId?: string;
129
+ }
130
+ /**
131
+ * The templates' `isQueued`, verbatim: the write did not land.
132
+ *
133
+ * Kept as its own export because 48 apps already branch on exactly this; for
134
+ * everything else prefer {@link toAirAppWriteOutcome}.
135
+ */
136
+ declare const isAirAppWriteQueued: (response: unknown) => boolean;
137
+ /**
138
+ * Normalize any write response — single create, single update, bulk create, bulk
139
+ * update — into an {@link AirAppWriteOutcome}.
140
+ *
141
+ * `queued` when `materialized === false` or `status === "in_review"` (the
142
+ * contract's pending branch). Also `queued` for a ChangeRequest in any other
143
+ * not-merged state (`changes_requested`, `conflict`, …): a fresh write does not
144
+ * normally come back in one, and if it ever does, "saved" would be a claim the
145
+ * response does not support. Everything else — a materialized record, a merged
146
+ * ChangeRequest, a bare record from an older server — is `saved`.
147
+ */
148
+ declare function toAirAppWriteOutcome(response: unknown): AirAppWriteOutcome;
149
+ /** The sentence an app shows for each outcome. */
150
+ interface AirAppWriteMessages {
151
+ saved: string;
152
+ queued: string;
153
+ }
154
+ /**
155
+ * Default copy. `queued` says *why* it is waiting (no write access here) and
156
+ * *what happens next* (someone who has it approves) — the two things a person
157
+ * who just clicked Save needs, so it does not read as "nothing happened".
158
+ *
159
+ * Locales outside this table fall back to `en`; an app passes its own strings
160
+ * through `messages` for those, or to change the wording.
161
+ */
162
+ declare const AIRAPP_WRITE_MESSAGES: Readonly<Record<string, Readonly<AirAppWriteMessages>>>;
163
+ /**
164
+ * Pick the closest table entry: exact tag, then Traditional/Simplified Chinese by
165
+ * script or region, then the bare language, then `en`.
166
+ */
167
+ declare function resolveAirAppWriteLocale(locale: string | undefined): string;
168
+ interface AirAppWriteMessageOptions {
169
+ /** BCP 47 tag. Defaults to `navigator.language`, then `en`. */
170
+ locale?: string;
171
+ /** The app's own wording; wins over the defaults for whichever keys it sets. */
172
+ messages?: Partial<AirAppWriteMessages>;
173
+ }
174
+ /**
175
+ * The sentence to show after a write.
176
+ *
177
+ * @example
178
+ * ```js
179
+ * const outcome = await records.upsert({ ... });
180
+ * state.notice = airAppWriteMessage(outcome, { locale: currentLocale() });
181
+ * ```
182
+ */
183
+ declare function airAppWriteMessage(outcome: Pick<AirAppWriteOutcome, "status">, options?: AirAppWriteMessageOptions): string;
184
+ //#endregion
185
+ //#region src/airapp-records.d.ts
186
+ type ListRecordsInput = NonNullable<Parameters<BusabaseClient["records"]["list"]>[0]>;
187
+ /** The client surface these helpers need. Both `BusabaseClient` and `Busabase#client` satisfy it. */
188
+ type AirAppRecordsClient = {
189
+ records: Pick<BusabaseClient["records"], "list" | "get" | "changeRequest">;
190
+ bases: Pick<BusabaseClient["bases"], "createChangeRequest">;
191
+ };
192
+ /**
193
+ * A record as these helpers return it: the canonical `RecordVO`, plus `fields` —
194
+ * its current field values keyed by slug, already pulled out of `headCommit`.
195
+ * Every copy re-derived that line (`record.headCommit?.payload ||
196
+ * record.headCommit?.fields || record.fields`); now it is a property.
197
+ */
198
+ type AirAppRecord = RecordVO & {
199
+ fields: Record<string, unknown>;
200
+ };
201
+ /** One page of records and the cursor for the next, `null` at the end. */
202
+ interface AirAppRecordPage {
203
+ records: AirAppRecord[];
204
+ nextCursor: string | null;
205
+ }
206
+ interface AirAppReadPageInput {
207
+ baseId: string;
208
+ /**
209
+ * The page budget for this one read — the app's declared `readLimit`. Defaults
210
+ * to 50, the documented interactive budget. One call reads one page; the next
211
+ * page is a separate call made for a separate user action.
212
+ */
213
+ limit?: number;
214
+ /** The previous page's `nextCursor`. `null`/`undefined`/`""` reads the first page. */
215
+ cursor?: string | null;
216
+ /** View filters (a best-effort superset — see `records.list`). */
217
+ filters?: ListRecordsInput["filters"];
218
+ /** Exact value comparisons — see `records.list`. */
219
+ valueFilters?: ListRecordsInput["valueFilters"];
220
+ /** One number or date field to sort by — see `records.list`. */
221
+ sort?: ListRecordsInput["sort"];
222
+ }
223
+ interface AirAppFindRecordInput {
224
+ baseId: string;
225
+ /** The field to match on, by slug — usually the app's business id (`"contact-id"`). */
226
+ fieldSlug: string;
227
+ /** The exact text value to match. */
228
+ valueText: string;
229
+ }
230
+ interface AirAppCreateRecordInput {
231
+ baseId: string;
232
+ /** Field values keyed by field slug, exactly as the Base declares them. */
233
+ fields: Record<string, unknown>;
234
+ /** Shown to whoever reviews it if it is queued. */
235
+ message?: string;
236
+ /** Dedupes a retried create (after a timeout, say) — see `bases.createChangeRequest`. */
237
+ idempotencyKey?: string;
238
+ }
239
+ interface AirAppUpdateRecordInput {
240
+ /** The record to change. */
241
+ recordId: string;
242
+ /**
243
+ * The `headCommitId` the change was made against, for optimistic concurrency:
244
+ * a record someone else changed in the meantime is not silently overwritten.
245
+ * Pass the one from the record you read.
246
+ */
247
+ baseCommitId?: string;
248
+ /** The fields to change, keyed by slug. Fields not named keep their value. */
249
+ fields: Record<string, unknown>;
250
+ message?: string;
251
+ }
252
+ interface AirAppUpsertRecordInput extends AirAppFindRecordInput {
253
+ /**
254
+ * Field values keyed by slug. On a create, the match field is filled in with
255
+ * `valueText` when `fields` leaves it out — otherwise the record could never
256
+ * be found by this same upsert again, and every call would create another.
257
+ */
258
+ fields: Record<string, unknown>;
259
+ message?: string;
260
+ }
261
+ /** The outcome of a single write, with the raw response kept for anything app-specific. */
262
+ interface AirAppWriteResult extends AirAppWriteOutcome {
263
+ /** The server's response, unchanged. */
264
+ response: unknown;
265
+ }
266
+ /** An upsert's outcome, plus which of the two writes it made. */
267
+ interface AirAppUpsertResult extends AirAppWriteResult {
268
+ action: "create" | "update";
269
+ }
270
+ interface AirAppRecordsOptions {
271
+ /**
272
+ * Recorded as `submittedBy` on creates and `author` on updates, so a reviewer
273
+ * can see which app proposed the change. Omit to use the server's default.
274
+ */
275
+ appId?: string;
276
+ }
277
+ interface AirAppRecords {
278
+ /** Read one page. Never loops: the next page is the caller's next call. */
279
+ readPage(input: AirAppReadPageInput): Promise<AirAppRecordPage>;
280
+ /** The one record whose `fieldSlug` is exactly `valueText`, or `null` when there is none. */
281
+ findRecord(input: AirAppFindRecordInput): Promise<AirAppRecord | null>;
282
+ /** Create a record. Permission-aware: never passes `autoMerge`. */
283
+ createRecord(input: AirAppCreateRecordInput): Promise<AirAppWriteResult>;
284
+ /** Change a record. Permission-aware: never passes `autoMerge`. */
285
+ updateRecord(input: AirAppUpdateRecordInput): Promise<AirAppWriteResult>;
286
+ /**
287
+ * Update the record whose `fieldSlug` is exactly `valueText`, or create one
288
+ * when there is none. The update is made against the `headCommitId` it just
289
+ * read. Permission-aware: never passes `autoMerge`.
290
+ */
291
+ upsert(input: AirAppUpsertRecordInput): Promise<AirAppUpsertResult>;
292
+ }
293
+ /** The `fields` property: the record's current values, out of its head commit. */
294
+ declare function airAppRecordFields(record: unknown): Record<string, unknown>;
295
+ /**
296
+ * Bind the record helpers to one client.
297
+ *
298
+ * The client is whatever the app already built — in an AirApp that is
299
+ * `createBusabaseClient({ baseUrl: window.location.origin })`, authenticated by
300
+ * the viewer's ambient session when hosted and by the local gateway's proxy when
301
+ * standalone. These helpers do not care which.
302
+ */
303
+ declare function createAirAppRecords(client: AirAppRecordsClient, options?: AirAppRecordsOptions): AirAppRecords;
304
+ //#endregion
2
305
  //#region src/airapp.d.ts
3
306
  type NodeChangeRequestInput = Parameters<BusabaseClient["nodes"]["createChangeRequest"]>[0];
4
307
  type NodeOperationInput = NodeChangeRequestInput["operations"][number];
@@ -314,4 +617,4 @@ declare function resolveAirAppFileAssets(client: AirAppPublishClient, files: Air
314
617
  */
315
618
  declare function publishAirApp(client: AirAppPublishClient, config: AirAppResourceConfig, files: AirAppFileInput[]): Promise<AirAppPublishResult>;
316
619
  //#endregion
317
- export { AirAppBaseDeclaration, AirAppFieldDeclaration, AirAppFileInput, AirAppFolderDeclaration, AirAppNodeDeclaration, AirAppOwnershipRepair, AirAppProvisionedBase, AirAppProvisioningClient, AirAppPublishClient, AirAppPublishResult, AirAppResourceConfig, AirAppResourceOwnership, AirAppResources, AirAppSetupCode, AirAppSetupError, buildAirAppFileOperations, buildProvisionOperations, inspectProvisionedResources, isNotFound, provisionDeclaredResources, publishAirApp, resolveAirAppFileAssets, resolveProvisionedFolder };
620
+ export { AIRAPP_RUNTIME_PATH, AIRAPP_RUNTIME_TIMEOUT_MS, AIRAPP_RUNTIME_UNKNOWN, AIRAPP_WRITE_MESSAGES, AirAppBaseDeclaration, type AirAppCreateRecordInput, AirAppFieldDeclaration, AirAppFileInput, type AirAppFindRecordInput, AirAppFolderDeclaration, AirAppNodeDeclaration, AirAppOwnershipRepair, AirAppProvisionedBase, AirAppProvisioningClient, AirAppPublishClient, AirAppPublishResult, type AirAppReadPageInput, type AirAppRecord, type AirAppRecordPage, type AirAppRecords, type AirAppRecordsClient, type AirAppRecordsOptions, AirAppResourceConfig, AirAppResourceOwnership, AirAppResources, type AirAppRuntimeInfo, AirAppSetupCode, AirAppSetupError, type AirAppUpdateRecordInput, type AirAppUpsertRecordInput, type AirAppUpsertResult, type AirAppWriteMessageOptions, type AirAppWriteMessages, type AirAppWriteOutcome, type AirAppWriteResponseLike, type AirAppWriteResult, type AirAppWriteStatus, type DetectAirAppRuntimeOptions, airAppRecordFields, airAppWriteMessage, buildAirAppFileOperations, buildProvisionOperations, createAirAppRecords, detectAirAppRuntime, inspectProvisionedResources, isAirAppWriteQueued, isNotFound, normalizeAirAppRuntime, provisionDeclaredResources, publishAirApp, resolveAirAppFileAssets, resolveAirAppWriteLocale, resolveProvisionedFolder, shouldUseLocalAirAppGateway, toAirAppWriteOutcome };
package/dist/airapp.js CHANGED
@@ -1,4 +1,321 @@
1
- import { a as uploadAsset, t as APP_ROOT_RESOURCE_KEY } from "./template-CMJMkyse.js";
1
+ import { o as uploadAsset, t as APP_ROOT_RESOURCE_KEY } from "./template-Bnkq4diD.js";
2
+ //#region src/airapp-browser-runtime.ts
3
+ /** Relative on purpose; see the module comment. */
4
+ const AIRAPP_RUNTIME_PATH = "__airapp/runtime";
5
+ /** How long the probe waits before resolving to `unknown`. */
6
+ const AIRAPP_RUNTIME_TIMEOUT_MS = 3e3;
7
+ /** The `unknown` outcome: the probe did not get a usable answer. */
8
+ const AIRAPP_RUNTIME_UNKNOWN = Object.freeze({
9
+ runtime: "unknown",
10
+ hosted: false,
11
+ devProxy: false,
12
+ determined: false
13
+ });
14
+ /**
15
+ * Turn a `__airapp/runtime` body into an {@link AirAppRuntimeInfo}. Anything that
16
+ * is not an object with a string `runtime` is `unknown`.
17
+ *
18
+ * `hosted` is `body.hosted === true` — the server's own answer, read as-is.
19
+ */
20
+ function normalizeAirAppRuntime(body) {
21
+ if (!body || typeof body !== "object") return { ...AIRAPP_RUNTIME_UNKNOWN };
22
+ const report = body;
23
+ if (typeof report.runtime !== "string") return { ...AIRAPP_RUNTIME_UNKNOWN };
24
+ return {
25
+ runtime: report.runtime,
26
+ hosted: report.hosted === true,
27
+ devProxy: report.devProxy === true,
28
+ determined: true
29
+ };
30
+ }
31
+ const probe = async (doFetch, timeoutMs) => {
32
+ const controller = typeof AbortController === "function" ? new AbortController() : void 0;
33
+ const timer = controller ? globalThis.setTimeout(() => controller.abort(), timeoutMs) : void 0;
34
+ try {
35
+ const response = await doFetch(AIRAPP_RUNTIME_PATH, {
36
+ headers: { accept: "application/json" },
37
+ ...controller ? { signal: controller.signal } : {}
38
+ });
39
+ const type = response.headers.get("content-type") || "";
40
+ if (!response.ok || !type.includes("application/json")) return { ...AIRAPP_RUNTIME_UNKNOWN };
41
+ return normalizeAirAppRuntime(await response.json());
42
+ } catch {
43
+ return { ...AIRAPP_RUNTIME_UNKNOWN };
44
+ } finally {
45
+ if (timer !== void 0) globalThis.clearTimeout(timer);
46
+ }
47
+ };
48
+ /**
49
+ * The page-wide answer lives on a global slot, not in module scope: an app that
50
+ * imports its modules both with and without a `?v=` cache-busting query loads
51
+ * this file as two module records with two scopes. A module-level cache would be
52
+ * filled by one copy and read empty by the other — the bug one template already
53
+ * hit with its own copy of this probe.
54
+ */
55
+ const SLOT = Symbol.for("busabase-sdk.airapp.runtime");
56
+ const slot = () => {
57
+ const holder = globalThis;
58
+ let current = holder[SLOT];
59
+ if (!current) {
60
+ current = {};
61
+ holder[SLOT] = current;
62
+ }
63
+ return current;
64
+ };
65
+ /**
66
+ * Ask the app's own server where it is running. Never rejects: a failed,
67
+ * non-JSON, or slow probe resolves to {@link AIRAPP_RUNTIME_UNKNOWN}.
68
+ *
69
+ * Called without an injected `fetch`, the first call probes and every later call
70
+ * on the page shares that answer, so the connect gate, the provider, and the
71
+ * settings panel can each ask without three round trips.
72
+ *
73
+ * @example
74
+ * ```js
75
+ * // app/js/runtime.js
76
+ * import { detectAirAppRuntime, shouldUseLocalAirAppGateway } from "../vendor/busabase-airapp.js";
77
+ *
78
+ * export const runtime = await detectAirAppRuntime();
79
+ * export const shouldUseLocalGateway = () => shouldUseLocalAirAppGateway(runtime);
80
+ * ```
81
+ */
82
+ function detectAirAppRuntime(options = {}) {
83
+ const timeoutMs = options.timeoutMs ?? 3e3;
84
+ if (options.fetch) return probe(options.fetch, timeoutMs);
85
+ const shared = slot();
86
+ if (!shared.pending) shared.pending = typeof globalThis.fetch === "function" ? probe(globalThis.fetch.bind(globalThis), timeoutMs) : Promise.resolve({ ...AIRAPP_RUNTIME_UNKNOWN });
87
+ return shared.pending;
88
+ }
89
+ /**
90
+ * Whether the app's local `/auth/*` connect gate applies — the predicate for
91
+ * `createAirAppConnectGate({ shouldGate })`.
92
+ *
93
+ * `!hosted`, so `unknown` shows the gate: an unnecessary gate is closed by the
94
+ * operator, a missing one strands them. Where the app runs never decides whether
95
+ * a write merges — Busabase decides that from the person's own permission.
96
+ */
97
+ const shouldUseLocalAirAppGateway = (runtime) => !runtime.hosted;
98
+ //#endregion
99
+ //#region src/airapp-write-outcome.ts
100
+ const isChangeRequestShape = (response) => Array.isArray(response.operations) || "primaryOperation" in response;
101
+ const recordIdsOf = (response, key) => (response.operations ?? []).map((operation) => operation?.[key]).filter((value) => typeof value === "string" && value !== "");
102
+ /**
103
+ * The templates' `isQueued`, verbatim: the write did not land.
104
+ *
105
+ * Kept as its own export because 48 apps already branch on exactly this; for
106
+ * everything else prefer {@link toAirAppWriteOutcome}.
107
+ */
108
+ const isAirAppWriteQueued = (response) => {
109
+ const value = response;
110
+ return value?.materialized === false || value?.status === "in_review";
111
+ };
112
+ /**
113
+ * Normalize any write response — single create, single update, bulk create, bulk
114
+ * update — into an {@link AirAppWriteOutcome}.
115
+ *
116
+ * `queued` when `materialized === false` or `status === "in_review"` (the
117
+ * contract's pending branch). Also `queued` for a ChangeRequest in any other
118
+ * not-merged state (`changes_requested`, `conflict`, …): a fresh write does not
119
+ * normally come back in one, and if it ever does, "saved" would be a claim the
120
+ * response does not support. Everything else — a materialized record, a merged
121
+ * ChangeRequest, a bare record from an older server — is `saved`.
122
+ */
123
+ function toAirAppWriteOutcome(response) {
124
+ const value = response ?? {};
125
+ const changeRequest = isChangeRequestShape(value);
126
+ if (isAirAppWriteQueued(value) || changeRequest && value.materialized !== true && value.status !== "merged") {
127
+ const targets = recordIdsOf(value, "targetRecordId");
128
+ const target = value.primaryOperation?.targetRecordId ?? (targets.length === 1 ? targets[0] : void 0);
129
+ return {
130
+ status: "queued",
131
+ ...target ? { recordId: target } : {},
132
+ ...value.id ? { changeRequestId: value.id } : {}
133
+ };
134
+ }
135
+ if (changeRequest) {
136
+ const merged = recordIdsOf(value, "mergedRecordId");
137
+ return {
138
+ status: "saved",
139
+ ...merged.length === 1 ? { recordId: merged[0] } : {},
140
+ ...merged.length ? { recordIds: merged } : {},
141
+ ...value.id ? { changeRequestId: value.id } : {}
142
+ };
143
+ }
144
+ return {
145
+ status: "saved",
146
+ ...value.id ? { recordId: value.id } : {},
147
+ ...value.changeRequestId ? { changeRequestId: value.changeRequestId } : {}
148
+ };
149
+ }
150
+ /**
151
+ * Default copy. `queued` says *why* it is waiting (no write access here) and
152
+ * *what happens next* (someone who has it approves) — the two things a person
153
+ * who just clicked Save needs, so it does not read as "nothing happened".
154
+ *
155
+ * Locales outside this table fall back to `en`; an app passes its own strings
156
+ * through `messages` for those, or to change the wording.
157
+ */
158
+ const AIRAPP_WRITE_MESSAGES = Object.freeze({
159
+ en: {
160
+ saved: "Saved.",
161
+ queued: "Sent for review. You don't have write access here, so this waits until someone who does approves it."
162
+ },
163
+ "zh-CN": {
164
+ saved: "已保存。",
165
+ queued: "已提交审核。你在这里没有写权限,需要有写权限的人批准后才会生效。"
166
+ },
167
+ "zh-TW": {
168
+ saved: "已儲存。",
169
+ queued: "已送出審核。你在這裡沒有寫入權限,需要有寫入權限的人核准後才會生效。"
170
+ },
171
+ ja: {
172
+ saved: "保存しました。",
173
+ queued: "レビューに送信しました。ここでの書き込み権限がないため、書き込み権限のある人が承認するまで反映されません。"
174
+ }
175
+ });
176
+ /**
177
+ * Pick the closest table entry: exact tag, then Traditional/Simplified Chinese by
178
+ * script or region, then the bare language, then `en`.
179
+ */
180
+ function resolveAirAppWriteLocale(locale) {
181
+ if (!locale) return "en";
182
+ if (AIRAPP_WRITE_MESSAGES[locale]) return locale;
183
+ const lower = locale.toLowerCase();
184
+ if (lower.startsWith("zh")) return /-(?:hant|tw|hk|mo)\b/.test(lower) ? "zh-TW" : "zh-CN";
185
+ const language = lower.split(/[-_]/)[0];
186
+ return AIRAPP_WRITE_MESSAGES[language] ? language : "en";
187
+ }
188
+ /**
189
+ * The sentence to show after a write.
190
+ *
191
+ * @example
192
+ * ```js
193
+ * const outcome = await records.upsert({ ... });
194
+ * state.notice = airAppWriteMessage(outcome, { locale: currentLocale() });
195
+ * ```
196
+ */
197
+ function airAppWriteMessage(outcome, options = {}) {
198
+ const browserLocale = typeof globalThis.navigator === "object" ? globalThis.navigator?.language : void 0;
199
+ const table = AIRAPP_WRITE_MESSAGES[resolveAirAppWriteLocale(options.locale ?? browserLocale)];
200
+ const key = outcome.status === "queued" ? "queued" : "saved";
201
+ return options.messages?.[key] ?? table[key];
202
+ }
203
+ //#endregion
204
+ //#region src/airapp-records.ts
205
+ /** The `fields` property: the record's current values, out of its head commit. */
206
+ function airAppRecordFields(record) {
207
+ const value = record;
208
+ const fields = value?.headCommit?.payload ?? value?.headCommit?.fields ?? value?.fields;
209
+ return fields && typeof fields === "object" && !Array.isArray(fields) ? fields : {};
210
+ }
211
+ const isNotFound$1 = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "NOT_FOUND" || "status" in error && error.status === 404);
212
+ const withFields = (record) => ({
213
+ ...record,
214
+ fields: airAppRecordFields(record)
215
+ });
216
+ /**
217
+ * Bind the record helpers to one client.
218
+ *
219
+ * The client is whatever the app already built — in an AirApp that is
220
+ * `createBusabaseClient({ baseUrl: window.location.origin })`, authenticated by
221
+ * the viewer's ambient session when hosted and by the local gateway's proxy when
222
+ * standalone. These helpers do not care which.
223
+ */
224
+ function createAirAppRecords(client, options = {}) {
225
+ const { appId } = options;
226
+ const readPage = async (input) => {
227
+ const result = await client.records.list({
228
+ baseId: input.baseId,
229
+ limit: input.limit ?? 50,
230
+ ...input.cursor ? { cursor: input.cursor } : {},
231
+ ...input.filters ? { filters: input.filters } : {},
232
+ ...input.valueFilters ? { valueFilters: input.valueFilters } : {},
233
+ ...input.sort ? { sort: input.sort } : {}
234
+ });
235
+ if (Array.isArray(result)) return {
236
+ records: result.filter((record) => record?.baseId === input.baseId).map(withFields),
237
+ nextCursor: null
238
+ };
239
+ const page = result;
240
+ return {
241
+ records: (page?.records ?? []).map(withFields),
242
+ nextCursor: page?.nextCursor || null
243
+ };
244
+ };
245
+ const findRecord = async ({ baseId, fieldSlug, valueText }) => {
246
+ if (!valueText) return null;
247
+ try {
248
+ return withFields(await client.records.get({
249
+ baseId,
250
+ fieldSlug,
251
+ valueText
252
+ }));
253
+ } catch (error) {
254
+ if (isNotFound$1(error)) return null;
255
+ throw error;
256
+ }
257
+ };
258
+ const createRecord = async (input) => {
259
+ const response = await client.bases.createChangeRequest({
260
+ baseId: input.baseId,
261
+ fields: input.fields,
262
+ ...input.message !== void 0 ? { message: input.message } : {},
263
+ ...appId ? { submittedBy: appId } : {},
264
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
265
+ });
266
+ return {
267
+ ...toAirAppWriteOutcome(response),
268
+ response
269
+ };
270
+ };
271
+ const updateRecord = async (input) => {
272
+ const response = await client.records.changeRequest({
273
+ recordId: input.recordId,
274
+ operation: "update",
275
+ fields: input.fields,
276
+ ...input.message !== void 0 ? { message: input.message } : {},
277
+ ...appId ? { author: appId } : {},
278
+ ...input.baseCommitId ? { baseCommitId: input.baseCommitId } : {}
279
+ });
280
+ return {
281
+ ...toAirAppWriteOutcome(response),
282
+ response
283
+ };
284
+ };
285
+ const upsert = async (input) => {
286
+ if (!input.valueText) throw new Error(`upsert: valueText for ${input.fieldSlug} is empty, so there is no record to match — pass the record's business id`);
287
+ const existing = await findRecord(input);
288
+ if (existing) return {
289
+ ...await updateRecord({
290
+ recordId: existing.id,
291
+ baseCommitId: existing.headCommitId,
292
+ fields: input.fields,
293
+ message: input.message
294
+ }),
295
+ action: "update"
296
+ };
297
+ const fields = input.fieldSlug in input.fields ? input.fields : {
298
+ ...input.fields,
299
+ [input.fieldSlug]: input.valueText
300
+ };
301
+ return {
302
+ ...await createRecord({
303
+ baseId: input.baseId,
304
+ fields,
305
+ message: input.message
306
+ }),
307
+ action: "create"
308
+ };
309
+ };
310
+ return {
311
+ readPage,
312
+ findRecord,
313
+ createRecord,
314
+ updateRecord,
315
+ upsert
316
+ };
317
+ }
318
+ //#endregion
2
319
  //#region src/airapp.ts
3
320
  /**
4
321
  * AirApp resource provisioning — how an app claims (or creates) the Folder and
@@ -545,4 +862,4 @@ async function publishAirApp(client, config, files) {
545
862
  };
546
863
  }
547
864
  //#endregion
548
- export { AirAppSetupError, buildAirAppFileOperations, buildProvisionOperations, inspectProvisionedResources, isNotFound, provisionDeclaredResources, publishAirApp, resolveAirAppFileAssets, resolveProvisionedFolder };
865
+ export { AIRAPP_RUNTIME_PATH, AIRAPP_RUNTIME_TIMEOUT_MS, AIRAPP_RUNTIME_UNKNOWN, AIRAPP_WRITE_MESSAGES, AirAppSetupError, airAppRecordFields, airAppWriteMessage, buildAirAppFileOperations, buildProvisionOperations, createAirAppRecords, detectAirAppRuntime, inspectProvisionedResources, isAirAppWriteQueued, isNotFound, normalizeAirAppRuntime, provisionDeclaredResources, publishAirApp, resolveAirAppFileAssets, resolveAirAppWriteLocale, resolveProvisionedFolder, shouldUseLocalAirAppGateway, toAirAppWriteOutcome };