@embeddables/forms 0.0.5 → 0.2.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.
@@ -31,119 +31,128 @@ var ValidatorError = class extends FormsError {
31
31
  this.name = "ValidatorError";
32
32
  }
33
33
  };
34
- const DEVELOPMENT_BASE_URL = void 0;
35
- /**
36
- * Returns null when persistence cannot be configured (missing publishable key
37
- * or fetch). The SDK keeps the no-op default in that case.
38
- */
39
- function resolvePersistenceConfig(config) {
40
- const core = config.core;
41
- const publishableKey = config.publishableKey ?? core.getPublishableKey();
42
- if (!publishableKey || !isValidPublishableKey({ value: publishableKey })) return null;
43
- const projectId = core.getProjectId();
44
- const appUserId = core.getAppUserId();
45
- if (!projectId || !appUserId) return null;
46
- const fetchImpl = config.fetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : void 0);
47
- if (!fetchImpl) return null;
48
- return {
49
- core,
50
- projectId,
51
- appUserId,
52
- publishableKey,
53
- baseUrl: config.baseUrl ?? DEVELOPMENT_BASE_URL ?? "https://backend-worker.heysavvy.workers.dev",
54
- fetch: fetchImpl,
55
- timeoutMs: config.timeoutMs ?? 1e4
56
- };
34
+ //#endregion
35
+ //#region src/types/form-file.ts
36
+ function contentTypeMatchesAccept({ contentType, accept }) {
37
+ const normalized = contentType.split(";", 1)[0]?.trim().toLowerCase() || "";
38
+ return accept.some((entry) => {
39
+ const pattern = entry.trim().toLowerCase();
40
+ if (pattern.endsWith("/*")) return normalized.startsWith(pattern.slice(0, -1));
41
+ return normalized === pattern;
42
+ });
43
+ }
44
+ function isFormFileRef(value) {
45
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
46
+ const record = value;
47
+ return typeof record.file_id === "string" && typeof record.name === "string" && typeof record.content_type === "string" && typeof record.size === "number" && Number.isFinite(record.size) && (record.status === "uploading" || record.status === "done" || record.status === "error");
57
48
  }
58
49
  //#endregion
59
- //#region src/storage/persistence.ts
50
+ //#region src/core/validation.ts
51
+ /** Runtime counterpart to `FieldType`. Exhaustive by construction. */
52
+ const FIELD_TYPE_PREDICATES = {
53
+ text: (value) => typeof value === "string",
54
+ email: (value) => typeof value === "string",
55
+ number: (value) => typeof value === "number",
56
+ boolean: (value) => typeof value === "boolean",
57
+ select: (value) => typeof value === "string",
58
+ multiselect: (value) => Array.isArray(value),
59
+ json: () => true,
60
+ file: (value) => value === null || isFormFileRef(value)
61
+ };
62
+ const EMAIL_PATTERN = /^[\w.!#$%&'*+/=?^`{|}~-]+@[a-zA-Z\d](?:[a-zA-Z\d-]{0,61}[a-zA-Z\d])?(?:\.[a-zA-Z\d](?:[a-zA-Z\d-]{0,61}[a-zA-Z\d])?)*$/;
60
63
  /**
61
- * The default: does nothing, never throws, and recovers nothing. With this in
62
- * place a form is pure local state, exactly as before the port existed.
64
+ * Every message a single field's value earns. Empty means valid. Not generic:
65
+ * the per-field types live at the instance boundary, and the cast down to
66
+ * `JsonValue` happens once, in `initForm`.
63
67
  */
64
- function createNoopPersistence() {
65
- return {
66
- savePartial: () => void 0,
67
- saveFields: () => void 0,
68
- saveSubmission: () => void 0,
69
- recoverRegistryFields: () => ({})
70
- };
71
- }
72
- //#endregion
73
- //#region src/storage/persistence-client.ts
74
- const PUBLISHABLE_KEY_HEADER = "x-publishable-key";
75
- const hcWithType = (...args) => hc(...args);
76
- function withTimeout(fetchImpl, timeoutMs) {
77
- return async (input, init) => {
78
- const controller = new AbortController();
79
- const timer = setTimeout(() => controller.abort(), timeoutMs);
80
- const path = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
81
- try {
82
- return await fetchImpl(input, {
83
- ...init,
84
- signal: controller.signal
85
- });
86
- } catch (error) {
87
- if (error instanceof Error && error.name === "AbortError") throw new Error(`Request to ${path} timed out after ${timeoutMs}ms`, { cause: error });
88
- throw new Error(`Request to ${path} failed to reach the API`, { cause: error });
89
- } finally {
90
- clearTimeout(timer);
68
+ function validateValue({ field, value, values, pattern, validator }) {
69
+ const rules = field.validations;
70
+ const messages = [];
71
+ const isAbsent = value === void 0 || value === null;
72
+ const isBlank = isAbsent || value === "" || Array.isArray(value) && value.length === 0;
73
+ if (rules?.required === true && isBlank) messages.push(`${field.label} is required`);
74
+ if (isAbsent) return messages;
75
+ if (!FIELD_TYPE_PREDICATES[field.type](value)) messages.push(`${field.label} expects ${/^[aeiou]/.test(field.type) ? "an" : "a"} ${field.type} value`);
76
+ if (typeof value === "string") {
77
+ if (field.type === "email" && value !== "" && !EMAIL_PATTERN.test(value)) messages.push(`${field.label} must be a valid email address`);
78
+ if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must be at least ${rules.minLength} characters`);
79
+ if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must be at most ${rules.maxLength} characters`);
80
+ if (pattern && !pattern.test(value)) messages.push(`${field.label} is not in the expected format`);
81
+ }
82
+ if (Array.isArray(value)) {
83
+ if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must have at least ${rules.minLength} items`);
84
+ if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must have at most ${rules.maxLength} items`);
85
+ }
86
+ if (typeof value === "number") {
87
+ if (rules?.min !== void 0 && value < rules.min) messages.push(`${field.label} must be at least ${rules.min}`);
88
+ if (rules?.max !== void 0 && value > rules.max) messages.push(`${field.label} must be at most ${rules.max}`);
89
+ }
90
+ if (field.type === "select" || field.type === "multiselect") {
91
+ const options = field.options;
92
+ if (options) {
93
+ const allowed = new Set(options.map((option) => option.value));
94
+ if (field.type === "select" && typeof value === "string" && !allowed.has(value)) messages.push(`${field.label} must be one of the allowed options`);
95
+ if (field.type === "multiselect" && Array.isArray(value)) {
96
+ if (value.some((entry) => typeof entry !== "string" || !allowed.has(entry))) messages.push(`${field.label} has values that are not allowed options`);
97
+ }
91
98
  }
92
- };
93
- }
94
- async function rpcCall(fn) {
95
- const res = await fn();
96
- if (!res.ok) {
97
- const problem = await res.json().catch(() => null);
98
- const message = problem?.detail ?? problem?.title ?? `forms persistence failed: ${res.status}`;
99
- throw new Error(message);
100
99
  }
101
- return res.json();
102
- }
103
- function createApiPersistence(config) {
104
- const root = config.baseUrl.replace(/\/+$/, "");
105
- const rpc = hcWithType(`${root}/forms`, {
106
- fetch: withTimeout(config.fetch, config.timeoutMs),
107
- headers: { [PUBLISHABLE_KEY_HEADER]: config.publishableKey }
100
+ if (rules?.oneOf) {
101
+ const encoded = canonicalize(value);
102
+ if (!rules.oneOf.some((option) => canonicalize(option) === encoded)) messages.push(`${field.label} must be one of the allowed options`);
103
+ }
104
+ if (field.type === "file" && isFormFileRef(value)) {
105
+ if (value.status !== "done") messages.push(`${field.label} upload is not complete`);
106
+ if (rules?.maxSize !== void 0 && value.size > rules.maxSize) messages.push(`${field.label} must be at most ${rules.maxSize} bytes`);
107
+ if (rules?.accept !== void 0 && !contentTypeMatchesAccept({
108
+ contentType: value.content_type,
109
+ accept: rules.accept
110
+ })) messages.push(`${field.label} must be one of the allowed file types`);
111
+ }
112
+ if (!validator || messages.length > 0) return messages;
113
+ return normalizeValidatorResult({
114
+ result: validator({
115
+ value,
116
+ values
117
+ }),
118
+ field
108
119
  });
109
- return {
110
- savePartial({ formKey, values }) {
111
- return rpcCall(() => rpc.v1.public.sessions.$post({ json: {
112
- project_id: config.projectId,
113
- app_user_id: config.appUserId,
114
- form_id: formKey,
115
- data: values
116
- } })).then(() => void 0);
117
- },
118
- saveFields: () => void 0,
119
- saveSubmission({ formKey, values }) {
120
- return rpcCall(() => rpc.v1.public.submissions.$post({ json: {
121
- project_id: config.projectId,
122
- app_user_id: config.appUserId,
123
- form_id: formKey,
124
- values
125
- } })).then(() => void 0);
126
- },
127
- recoverRegistryFields: () => ({})
128
- };
129
120
  }
130
- function resolveDefaultPersistence(config) {
131
- const resolved = resolvePersistenceConfig(config);
132
- if (!resolved) return createNoopPersistence();
133
- return createApiPersistence(resolved);
121
+ function normalizeValidatorResult({ result, field }) {
122
+ if (isThenable(result)) throw new ValidatorError(`Validator for "${field.key}" returned a promise; custom validators must be synchronous`);
123
+ if (result === null || result === void 0) return [];
124
+ if (typeof result === "string") return [result];
125
+ if (Array.isArray(result)) return result.filter((entry) => typeof entry === "string");
126
+ throw new ValidatorError(`Validator for "${field.key}" returned ${typeof result}; expected a string, an array of strings, or null`);
127
+ }
128
+ function isThenable(value) {
129
+ return typeof value?.then === "function";
130
+ }
131
+ /**
132
+ * JSON encoding with object keys sorted at every depth, so two values compare
133
+ * by content rather than by insertion order.
134
+ */
135
+ function canonicalize(value) {
136
+ const walk = (input) => {
137
+ if (Array.isArray(input)) return input.map(walk);
138
+ if (input !== null && typeof input === "object") return Object.fromEntries(Object.entries(input).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, item]) => [key, walk(item)]));
139
+ return input;
140
+ };
141
+ return JSON.stringify(walk(value));
134
142
  }
135
143
  //#endregion
136
144
  //#region src/storage/storage.ts
137
145
  /** Every form on the origin shares this one entry, indexed by form ID. */
138
146
  const FORM_DATA_KEY = "EMBEDDABLES-FORM-DATA";
139
- const FIELD_TYPES$1 = [
147
+ const FIELD_TYPES$2 = [
140
148
  "text",
141
149
  "email",
142
150
  "number",
143
151
  "boolean",
144
152
  "select",
145
153
  "multiselect",
146
- "json"
154
+ "json",
155
+ "file"
147
156
  ];
148
157
  const LIVE_DOCUMENTS = /* @__PURE__ */ new WeakMap();
149
158
  function readDocumentFromStorage({ storage }) {
@@ -151,7 +160,7 @@ function readDocumentFromStorage({ storage }) {
151
160
  const raw = storage.getItem(FORM_DATA_KEY);
152
161
  if (raw === null) return {};
153
162
  const parsed = JSON.parse(raw);
154
- if (!isObjectLike$1(parsed)) return {};
163
+ if (!isObjectLike$2(parsed)) return {};
155
164
  return parsed;
156
165
  } catch {
157
166
  return {};
@@ -178,7 +187,7 @@ function resolveStorage({ storage }) {
178
187
  candidate.getItem(FORM_DATA_KEY);
179
188
  return candidate;
180
189
  } catch {
181
- return createMemoryStorage();
190
+ return createMemoryFormsStorage();
182
191
  }
183
192
  }
184
193
  /**
@@ -186,7 +195,7 @@ function resolveStorage({ storage }) {
186
195
  * whose real storage started throwing mid-session.
187
196
  */
188
197
  function degradeToMemory({ storage }) {
189
- const shim = createMemoryStorage();
198
+ const shim = createMemoryFormsStorage();
190
199
  const snapshot = { ...LIVE_DOCUMENTS.get(storage) ?? readDocumentFromStorage({ storage }) };
191
200
  shim.setItem(FORM_DATA_KEY, JSON.stringify(snapshot));
192
201
  LIVE_DOCUMENTS.set(shim, snapshot);
@@ -194,18 +203,18 @@ function degradeToMemory({ storage }) {
194
203
  }
195
204
  function readFields({ storage, formKey, fieldDefinitions }) {
196
205
  const bag = loadDocument({ storage })[formKey];
197
- if (!isObjectLike$1(bag)) return {};
206
+ if (!isObjectLike$2(bag)) return {};
198
207
  const declared = new Set(fieldDefinitions.map((field) => field.key));
199
208
  const values = {};
200
209
  for (const [key, entry] of Object.entries(bag)) {
201
210
  if (!declared.has(key)) continue;
202
- if (isStoredField(entry)) values[key] = entry.value;
211
+ if (isStoredField$1(entry)) values[key] = entry.value;
203
212
  }
204
213
  return values;
205
214
  }
206
215
  function writeFields({ storage, formKey, fields, fieldDefinitions }) {
207
216
  const current = loadDocument({ storage });
208
- const currentForm = isObjectLike$1(current[formKey]) ? current[formKey] : {};
217
+ const currentForm = isObjectLike$2(current[formKey]) ? current[formKey] : {};
209
218
  const declaredKeys = new Set(fieldDefinitions.map((field) => field.key));
210
219
  const nextForm = {};
211
220
  for (const [key, entry] of Object.entries(currentForm)) if (!declaredKeys.has(key)) nextForm[key] = entry;
@@ -250,7 +259,8 @@ function isSerializable({ value }) {
250
259
  return false;
251
260
  }
252
261
  }
253
- function createMemoryStorage() {
262
+ /** In-memory storage for server-side form init and tests that inject a store. */
263
+ function createMemoryFormsStorage() {
254
264
  const entries = /* @__PURE__ */ new Map();
255
265
  return {
256
266
  getItem: (key) => entries.get(key) ?? null,
@@ -262,6 +272,47 @@ function createMemoryStorage() {
262
272
  }
263
273
  };
264
274
  }
275
+ function isObjectLike$2(value) {
276
+ return typeof value === "object" && value !== null && !Array.isArray(value);
277
+ }
278
+ function isStoredField$1(value) {
279
+ if (!isObjectLike$2(value) || !Object.hasOwn(value, "value")) return false;
280
+ if (!isSerializable({ value: value["value"] })) return false;
281
+ if (typeof value["type"] !== "string" || !FIELD_TYPES$2.includes(value["type"]) || typeof value["label"] !== "string") return false;
282
+ if (value["registryId"] !== void 0 && typeof value["registryId"] !== "string") return false;
283
+ if (value["protocolFieldId"] !== void 0 && typeof value["protocolFieldId"] !== "string") return false;
284
+ return true;
285
+ }
286
+ //#endregion
287
+ //#region src/storage/cookie-form-data.ts
288
+ const FIELD_TYPES$1 = [
289
+ "text",
290
+ "email",
291
+ "number",
292
+ "boolean",
293
+ "select",
294
+ "multiselect",
295
+ "json"
296
+ ];
297
+ function formatFormCookieDataKey({ projectId, formId }) {
298
+ return `EMBEDDABLES--${projectId}--COOKIES-FORM-DATA--${formId}`;
299
+ }
300
+ function buildCookiePayloadFromBag({ bag, fieldDefinitions }) {
301
+ const payload = {};
302
+ for (const field of fieldDefinitions) {
303
+ if (field.includeInCookies !== true) continue;
304
+ const value = bag[field.key];
305
+ if (value === void 0) continue;
306
+ payload[field.key] = {
307
+ value,
308
+ type: field.type,
309
+ label: field.label,
310
+ ...field.registryId === void 0 ? {} : { registryId: field.registryId },
311
+ ...field.protocolFieldId === void 0 ? {} : { protocolFieldId: field.protocolFieldId }
312
+ };
313
+ }
314
+ return payload;
315
+ }
265
316
  function isObjectLike$1(value) {
266
317
  return typeof value === "object" && value !== null && !Array.isArray(value);
267
318
  }
@@ -273,6 +324,265 @@ function isStoredField(value) {
273
324
  if (value["protocolFieldId"] !== void 0 && typeof value["protocolFieldId"] !== "string") return false;
274
325
  return true;
275
326
  }
327
+ function entryMatchesFieldDefinition(entry, field) {
328
+ if (entry.type !== field.type || entry.label !== field.label) return false;
329
+ if ((entry.registryId ?? void 0) !== (field.registryId ?? void 0)) return false;
330
+ if ((entry.protocolFieldId ?? void 0) !== (field.protocolFieldId ?? void 0)) return false;
331
+ return FIELD_TYPE_PREDICATES[field.type](entry.value);
332
+ }
333
+ function serializeBrowserCookie(key, value) {
334
+ const locationRef = globalThis.location;
335
+ const parts = [
336
+ `${key}=${encodeURIComponent(value)}`,
337
+ "Path=/",
338
+ "SameSite=Lax"
339
+ ];
340
+ if (locationRef?.protocol === "https:") parts.push("Secure");
341
+ return parts.join("; ");
342
+ }
343
+ function writeBrowserCookie(key, value) {
344
+ const documentRef = globalThis.document;
345
+ if (!documentRef) return;
346
+ try {
347
+ documentRef.cookie = serializeBrowserCookie(key, value);
348
+ } catch {}
349
+ }
350
+ function clearBrowserCookie(key) {
351
+ const documentRef = globalThis.document;
352
+ if (!documentRef) return;
353
+ try {
354
+ const locationRef = globalThis.location;
355
+ const parts = [
356
+ `${key}=`,
357
+ "Path=/",
358
+ "Max-Age=0",
359
+ "SameSite=Lax"
360
+ ];
361
+ if (locationRef?.protocol === "https:") parts.push("Secure");
362
+ documentRef.cookie = parts.join("; ");
363
+ } catch {}
364
+ }
365
+ function writeFormCookieData({ projectId, formId, bag, fieldDefinitions }) {
366
+ try {
367
+ const payload = buildCookiePayloadFromBag({
368
+ bag,
369
+ fieldDefinitions
370
+ });
371
+ writeBrowserCookie(formatFormCookieDataKey({
372
+ projectId,
373
+ formId
374
+ }), JSON.stringify(payload));
375
+ } catch {}
376
+ }
377
+ function clearFormCookieData({ projectId, formId }) {
378
+ try {
379
+ clearBrowserCookie(formatFormCookieDataKey({
380
+ projectId,
381
+ formId
382
+ }));
383
+ } catch {}
384
+ }
385
+ function readFormCookieStoredForm({ projectId, formId, getCookie, fieldDefinitions }) {
386
+ try {
387
+ const raw = getCookie(formatFormCookieDataKey({
388
+ projectId,
389
+ formId
390
+ }));
391
+ if (raw === null || raw === "") return {};
392
+ const parsed = JSON.parse(decodeURIComponent(raw));
393
+ if (!isObjectLike$1(parsed)) return {};
394
+ const optedInFields = new Map(fieldDefinitions.filter((field) => field.includeInCookies === true).map((field) => [field.key, field]));
395
+ const storedForm = {};
396
+ for (const [key, entry] of Object.entries(parsed)) {
397
+ const field = optedInFields.get(key);
398
+ if (!field || !isStoredField(entry)) continue;
399
+ if (!entryMatchesFieldDefinition(entry, field)) continue;
400
+ storedForm[key] = entry;
401
+ }
402
+ return storedForm;
403
+ } catch {
404
+ return {};
405
+ }
406
+ }
407
+ function seedServerFormsStorageFromCookies({ storage, projectId, formIds, getFormSchema, getCookie }) {
408
+ const document = {};
409
+ for (const formId of formIds) {
410
+ const rawSchema = getFormSchema(formId);
411
+ if (typeof rawSchema !== "object" || rawSchema === null) continue;
412
+ const storedForm = readFormCookieStoredForm({
413
+ projectId,
414
+ formId,
415
+ getCookie,
416
+ fieldDefinitions: rawSchema.fields
417
+ });
418
+ if (Object.keys(storedForm).length === 0) continue;
419
+ document[formId] = storedForm;
420
+ }
421
+ if (Object.keys(document).length === 0) return;
422
+ storage.setItem(FORM_DATA_KEY, JSON.stringify(document));
423
+ }
424
+ const DEVELOPMENT_BASE_URL = void 0;
425
+ /**
426
+ * Returns null when persistence cannot be configured (missing publishable key
427
+ * or fetch). The SDK keeps the no-op default in that case.
428
+ */
429
+ function resolvePersistenceConfig(config) {
430
+ const core = config.core;
431
+ const publishableKey = config.publishableKey ?? core.getPublishableKey();
432
+ if (!publishableKey || !isValidPublishableKey({ value: publishableKey })) return null;
433
+ const projectId = core.getProjectId();
434
+ const appUserId = core.getAppUserId();
435
+ if (!projectId || !appUserId) return null;
436
+ const fetchImpl = config.fetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : void 0);
437
+ if (!fetchImpl) return null;
438
+ return {
439
+ core,
440
+ projectId,
441
+ appUserId,
442
+ publishableKey,
443
+ baseUrl: config.baseUrl ?? DEVELOPMENT_BASE_URL ?? "https://backend-worker.heysavvy.workers.dev",
444
+ fetch: fetchImpl,
445
+ timeoutMs: config.timeoutMs ?? 1e4
446
+ };
447
+ }
448
+ //#endregion
449
+ //#region src/storage/persistence.ts
450
+ /**
451
+ * The default: does nothing, never throws, and recovers nothing. With this in
452
+ * place a form is pure local state, exactly as before the port existed.
453
+ */
454
+ function createNoopPersistence() {
455
+ return {
456
+ savePartial: () => void 0,
457
+ saveFields: () => void 0,
458
+ saveSubmission: () => void 0,
459
+ recoverRegistryFields: () => ({})
460
+ };
461
+ }
462
+ //#endregion
463
+ //#region src/storage/persistence-client.ts
464
+ const PUBLISHABLE_KEY_HEADER$1 = "x-publishable-key";
465
+ const hcWithType = (...args) => hc(...args);
466
+ function withTimeout(fetchImpl, timeoutMs) {
467
+ return async (input, init) => {
468
+ const controller = new AbortController();
469
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
470
+ const path = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
471
+ try {
472
+ return await fetchImpl(input, {
473
+ ...init,
474
+ signal: controller.signal
475
+ });
476
+ } catch (error) {
477
+ if (error instanceof Error && error.name === "AbortError") throw new Error(`Request to ${path} timed out after ${timeoutMs}ms`, { cause: error });
478
+ throw new Error(`Request to ${path} failed to reach the API`, { cause: error });
479
+ } finally {
480
+ clearTimeout(timer);
481
+ }
482
+ };
483
+ }
484
+ async function rpcCall(fn) {
485
+ const res = await fn();
486
+ if (!res.ok) {
487
+ const problem = await res.json().catch(() => null);
488
+ const message = problem?.detail ?? problem?.title ?? `forms persistence failed: ${res.status}`;
489
+ throw new Error(message);
490
+ }
491
+ return res.json();
492
+ }
493
+ function createApiPersistence(config) {
494
+ const root = config.baseUrl.replace(/\/+$/, "");
495
+ const rpc = hcWithType(`${root}/forms`, {
496
+ fetch: withTimeout(config.fetch, config.timeoutMs),
497
+ headers: { [PUBLISHABLE_KEY_HEADER$1]: config.publishableKey }
498
+ });
499
+ return {
500
+ savePartial({ formKey, values }) {
501
+ return rpcCall(() => rpc.v1.public.sessions.$post({ json: {
502
+ project_id: config.projectId,
503
+ app_user_id: config.appUserId,
504
+ form_id: formKey,
505
+ data: values
506
+ } })).then(() => void 0);
507
+ },
508
+ saveFields: () => void 0,
509
+ saveSubmission({ formKey, values }) {
510
+ return rpcCall(() => rpc.v1.public.submissions.$post({ json: {
511
+ project_id: config.projectId,
512
+ app_user_id: config.appUserId,
513
+ form_id: formKey,
514
+ values
515
+ } })).then(() => void 0);
516
+ },
517
+ recoverRegistryFields: () => ({})
518
+ };
519
+ }
520
+ function resolveDefaultPersistence(config) {
521
+ const resolved = resolvePersistenceConfig(config);
522
+ if (!resolved) return createNoopPersistence();
523
+ return createApiPersistence(resolved);
524
+ }
525
+ //#endregion
526
+ //#region src/storage/upload-client.ts
527
+ const PUBLISHABLE_KEY_HEADER = "x-publishable-key";
528
+ function mapUploadErrorMessage(problem, status) {
529
+ const code = problem?.code;
530
+ if (code === "validation.file_too_large") return problem?.detail ?? "Maximum upload size is 25 MiB.";
531
+ if (code === "validation.unsupported_content_type") return problem?.detail ?? "Unsupported file type.";
532
+ if (code === "validation.upload_failed") return problem?.detail ?? "Upload could not be completed.";
533
+ if (code === "service.form_uploads_not_configured") return problem?.detail ?? "File uploads are not configured for this project.";
534
+ if (code === "validation.failed") return problem?.detail ?? "Upload metadata is invalid.";
535
+ return problem?.detail ?? problem?.title ?? `File upload failed (${status}).`;
536
+ }
537
+ function uploadResultToFormFileRef(result) {
538
+ return {
539
+ file_id: result.file_id,
540
+ name: result.name,
541
+ content_type: result.content_type,
542
+ size: result.size,
543
+ status: "done",
544
+ uploaded_at: result.uploaded_at
545
+ };
546
+ }
547
+ function toUploadFetchError(error, path, timeoutMs) {
548
+ if (error instanceof FormsError) return error;
549
+ if (error instanceof Error && error.name === "AbortError") return new FormsError(`Request to ${path} timed out after ${timeoutMs}ms`, { cause: error });
550
+ return new FormsError(`Request to ${path} failed to reach the API`, { cause: error });
551
+ }
552
+ function toUploadDecodeError(error, path) {
553
+ return new FormsError(`Upload response from ${path} could not be decoded`, { cause: error });
554
+ }
555
+ async function uploadFormFile(config, input) {
556
+ const url = `${config.baseUrl.replace(/\/+$/, "")}/forms/v1/public/uploads`;
557
+ const formData = new FormData();
558
+ formData.append("project_id", config.projectId);
559
+ formData.append("app_user_id", config.appUserId);
560
+ formData.append("form_id", input.formId);
561
+ formData.append("field_key", input.fieldKey);
562
+ const fileName = input.fileName ?? (typeof File !== "undefined" && input.file instanceof File ? input.file.name : "upload");
563
+ formData.append("file", input.file, fileName);
564
+ const controller = new AbortController();
565
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs);
566
+ try {
567
+ const res = await config.fetch(url, {
568
+ method: "POST",
569
+ headers: { [PUBLISHABLE_KEY_HEADER]: config.publishableKey },
570
+ body: formData,
571
+ signal: controller.signal
572
+ });
573
+ clearTimeout(timer);
574
+ if (!res.ok) throw new FormsError(mapUploadErrorMessage(await res.json().catch(() => null), res.status));
575
+ try {
576
+ return uploadResultToFormFileRef(await res.json());
577
+ } catch (error) {
578
+ throw toUploadDecodeError(error, url);
579
+ }
580
+ } catch (error) {
581
+ throw toUploadFetchError(error, url, config.timeoutMs);
582
+ } finally {
583
+ clearTimeout(timer);
584
+ }
585
+ }
276
586
  //#endregion
277
587
  //#region src/core/analytics.ts
278
588
  /** The ingest bound on a `data:updated` entry's `value`. */
@@ -288,10 +598,11 @@ function mapFieldUpdatedType(type) {
288
598
  * raw `field_value`; only the batch event caps and stringifies for ingest.
289
599
  */
290
600
  function formatFieldValue({ value }) {
601
+ if (value === void 0) return "";
291
602
  return (typeof value === "string" ? value : JSON.stringify(value)).slice(0, MAX_VALUE_LENGTH);
292
603
  }
293
604
  /** One event carrying every key in one `.set()` call. */
294
- function buildDataUpdatedEvent({ fields, patch }) {
605
+ function buildDataUpdatedEvent({ fields, patch, formId }) {
295
606
  const byKey = new Map(fields.map((field) => [field.key, field]));
296
607
  return {
297
608
  event_name: "data:updated",
@@ -304,11 +615,12 @@ function buildDataUpdatedEvent({ fields, patch }) {
304
615
  }),
305
616
  label: (field?.label ?? key).slice(0, MAX_LABEL_LENGTH)
306
617
  }];
307
- }))
618
+ })),
619
+ form_id: formId
308
620
  };
309
621
  }
310
622
  /** One `field:updated` per changed key, emitted alongside `data:updated`. */
311
- function buildFieldUpdatedEvents({ fields, patch }) {
623
+ function buildFieldUpdatedEvents({ fields, patch, formId }) {
312
624
  const byKey = new Map(fields.map((field) => [field.key, field]));
313
625
  return Object.entries(patch).map(([key, value]) => {
314
626
  const field = byKey.get(key);
@@ -316,14 +628,33 @@ function buildFieldUpdatedEvents({ fields, patch }) {
316
628
  event_name: "field:updated",
317
629
  field_key: key,
318
630
  field_type: mapFieldUpdatedType(field?.type ?? "text"),
319
- field_value: value
631
+ form_id: formId
320
632
  };
633
+ if (value !== void 0) event.field_value = value;
321
634
  if (field?.registryId !== void 0) event.registry_field_id = field.registryId;
322
635
  if (field?.protocolFieldId !== void 0) event.protocol_field_id = field.protocolFieldId;
323
636
  return event;
324
637
  });
325
638
  }
326
639
  //#endregion
640
+ //#region src/core/options.ts
641
+ /**
642
+ * The array a `multiselect` patch should actually store, given what it added
643
+ * relative to the previous value. An added exclusive option wins outright; an
644
+ * added regular option evicts every exclusive value; a patch that only removed
645
+ * values passes through.
646
+ */
647
+ function normalizeExclusiveSelection({ next, previous, options }) {
648
+ const exclusive = new Set(options.filter((option) => option.exclusive === true).map((option) => option.value));
649
+ if (exclusive.size === 0) return next;
650
+ const previousValues = new Set(previous);
651
+ const added = next.filter((entry) => !previousValues.has(entry));
652
+ const addedExclusive = added.filter((entry) => exclusive.has(entry));
653
+ if (addedExclusive.length > 0) return [addedExclusive[addedExclusive.length - 1]];
654
+ if (added.length > 0) return next.filter((entry) => !exclusive.has(entry));
655
+ return next;
656
+ }
657
+ //#endregion
327
658
  //#region src/core/resolve.ts
328
659
  const FIELD_TYPES = [
329
660
  "text",
@@ -332,7 +663,8 @@ const FIELD_TYPES = [
332
663
  "boolean",
333
664
  "select",
334
665
  "multiselect",
335
- "json"
666
+ "json",
667
+ "file"
336
668
  ];
337
669
  const VALIDATION_RULES = [
338
670
  "required",
@@ -341,21 +673,31 @@ const VALIDATION_RULES = [
341
673
  "min",
342
674
  "max",
343
675
  "pattern",
344
- "patternFlags",
345
676
  "oneOf",
677
+ "accept",
678
+ "maxSize",
346
679
  "custom"
347
680
  ];
348
681
  const NUMERIC_RULES = [
349
682
  "minLength",
350
683
  "maxLength",
351
684
  "min",
352
- "max"
685
+ "max",
686
+ "maxSize"
687
+ ];
688
+ const FILE_ONLY_RULES = ["accept", "maxSize"];
689
+ const OPTION_KEYS = [
690
+ "value",
691
+ "label",
692
+ "exclusive"
353
693
  ];
694
+ /** The two field types whose choices are declared through a field-level `options`. */
695
+ const OPTION_FIELD_TYPES = ["select", "multiselect"];
354
696
  /** The ingest `z.string().max(128)` bound on a `data:updated` key. */
355
697
  const MAX_FIELD_KEY_LENGTH = 128;
356
698
  /** The ingest `z.string().max(128)` bound on a `form:submitted` key. */
357
699
  const MAX_FORM_KEY_LENGTH = 128;
358
- const VALID_PATTERN_FLAGS = /^[dgimsuvy]*$/;
700
+ const INLINE_PATTERN_FLAGS = /^\(\?([dgimsuvy]+)\)/;
359
701
  const RESOLVED_SCHEMAS = /* @__PURE__ */ new WeakMap();
360
702
  function resolveForm({ schema }) {
361
703
  const memoized = RESOLVED_SCHEMAS.get(schema);
@@ -411,25 +753,56 @@ function validateField({ field, path, seenKeys, patterns }) {
411
753
  if (registryId !== void 0 && typeof registryId !== "string") throw new SchemaError(`${path}.registryId: must be a string`);
412
754
  const protocolFieldId = field["protocolFieldId"];
413
755
  if (protocolFieldId !== void 0 && typeof protocolFieldId !== "string") throw new SchemaError(`${path}.protocolFieldId: must be a string`);
756
+ const includeInCookies = field["includeInCookies"];
757
+ if (includeInCookies !== void 0 && typeof includeInCookies !== "boolean") throw new SchemaError(`${path}.includeInCookies: must be a boolean`);
758
+ validateOptions({
759
+ field,
760
+ type,
761
+ path
762
+ });
414
763
  const validations = field["validations"];
415
764
  if (validations === void 0) return;
416
765
  validateValidations({
417
766
  validations,
767
+ type,
418
768
  path: `${path}.validations`
419
769
  });
420
770
  if (!isObjectLike(validations)) return;
421
771
  const pattern = validations["pattern"];
422
772
  if (typeof pattern !== "string") return;
423
- const flags = validations["patternFlags"];
424
773
  patterns.set(key, compilePattern({
425
774
  pattern,
426
- flags: typeof flags === "string" ? flags : "",
427
775
  path: `${path}.validations.pattern`
428
776
  }));
429
777
  }
430
- function validateValidations({ validations, path }) {
778
+ function validateOptions({ field, type, path }) {
779
+ const options = field["options"];
780
+ if (options === void 0) return;
781
+ if (!OPTION_FIELD_TYPES.includes(type)) throw new SchemaError(`${path}.options: only a select or multiselect field may declare options`);
782
+ if (!(Array.isArray(options) && options.length > 0)) throw new SchemaError(`${path}.options: must be a non-empty array`);
783
+ const entries = options;
784
+ const seenValues = /* @__PURE__ */ new Set();
785
+ entries.forEach((option, index) => {
786
+ const at = `${path}.options[${index}]`;
787
+ if (!isObjectLike(option)) throw new SchemaError(`${at}: must be an object`);
788
+ for (const optionKey of Object.keys(option)) if (!OPTION_KEYS.includes(optionKey)) throw new SchemaError(`${at}.${optionKey}: unknown option key; expected one of ${OPTION_KEYS.join(", ")}`);
789
+ const value = option["value"];
790
+ if (typeof value !== "string" || value.trim() === "") throw new SchemaError(`${at}.value: must be a non-empty string`);
791
+ const label = option["label"];
792
+ if (label !== void 0 && typeof label !== "string") throw new SchemaError(`${at}.label: must be a string`);
793
+ const exclusive = option["exclusive"];
794
+ if (exclusive !== void 0 && typeof exclusive !== "boolean") throw new SchemaError(`${at}.exclusive: must be a boolean`);
795
+ if (exclusive === true && type === "select") throw new SchemaError(`${at}.exclusive: only a multiselect field may declare an exclusive option`);
796
+ if (seenValues.has(value)) throw new SchemaError(`${at}.value: duplicate option value "${value}" on this field`);
797
+ seenValues.add(value);
798
+ });
799
+ }
800
+ function validateValidations({ validations, type, path }) {
431
801
  if (!isObjectLike(validations)) throw new SchemaError(`${path}: must be an object`);
432
- for (const rule of Object.keys(validations)) if (!VALIDATION_RULES.includes(rule)) throw new SchemaError(`${path}.${rule}: unknown validation rule; expected one of ${VALIDATION_RULES.join(", ")}`);
802
+ for (const rule of Object.keys(validations)) {
803
+ if (!VALIDATION_RULES.includes(rule)) throw new SchemaError(`${path}.${rule}: unknown validation rule; expected one of ${VALIDATION_RULES.join(", ")}`);
804
+ if (type !== "file" && FILE_ONLY_RULES.includes(rule)) throw new SchemaError(`${path}.${rule}: is only valid for file fields`);
805
+ }
433
806
  const required = validations["required"];
434
807
  if (required !== void 0 && typeof required !== "boolean") throw new SchemaError(`${path}.required: must be a boolean`);
435
808
  for (const rule of NUMERIC_RULES) {
@@ -443,17 +816,23 @@ function validateValidations({ validations, path }) {
443
816
  const max = validations["max"];
444
817
  if (typeof min === "number" && typeof max === "number" && max < min) throw new SchemaError(`${path}.max: must be greater than or equal to min`);
445
818
  const oneOf = validations["oneOf"];
819
+ if (oneOf !== void 0 && OPTION_FIELD_TYPES.includes(type)) throw new SchemaError(`${path}.oneOf: not allowed on a ${type} field; declare choices through the field's options instead`);
446
820
  if (oneOf !== void 0 && !(Array.isArray(oneOf) && oneOf.length > 0)) throw new SchemaError(`${path}.oneOf: must be a non-empty array`);
821
+ const accept = validations["accept"];
822
+ if (accept !== void 0 && !(Array.isArray(accept) && accept.length > 0 && accept.every((entry) => typeof entry === "string" && entry.trim() !== ""))) throw new SchemaError(`${path}.accept: must be a non-empty array of strings`);
823
+ const maxSize = validations["maxSize"];
824
+ if (maxSize !== void 0 && !(typeof maxSize === "number" && Number.isFinite(maxSize) && maxSize > 0)) throw new SchemaError(`${path}.maxSize: must be a positive finite number`);
447
825
  const pattern = validations["pattern"];
448
826
  if (pattern !== void 0 && typeof pattern !== "string") throw new SchemaError(`${path}.pattern: must be a string`);
449
- const patternFlags = validations["patternFlags"];
450
- if (patternFlags !== void 0 && !(typeof patternFlags === "string" && VALID_PATTERN_FLAGS.test(patternFlags))) throw new SchemaError(`${path}.patternFlags: must contain only the characters dgimsuvy`);
451
827
  const custom = validations["custom"];
452
828
  if (custom !== void 0 && typeof custom !== "function") throw new SchemaError(`${path}.custom: must be a function (received ${typeof custom})`);
453
829
  }
454
- function compilePattern({ pattern, flags, path }) {
830
+ function compilePattern({ pattern, path }) {
831
+ const prefix = INLINE_PATTERN_FLAGS.exec(pattern);
832
+ const source = prefix ? pattern.slice(prefix[0].length) : pattern;
833
+ const flags = prefix?.[1] ?? "";
455
834
  try {
456
- return new RegExp(pattern, flags.replace(/[gy]/g, ""));
835
+ return new RegExp(source, flags.replace(/[gy]/g, ""));
457
836
  } catch (error) {
458
837
  throw new SchemaError(`${path}: invalid pattern — ${errorMessage(error)}`, { cause: error });
459
838
  }
@@ -576,81 +955,6 @@ function errorMessage(error) {
576
955
  return error instanceof Error ? error.message : String(error);
577
956
  }
578
957
  //#endregion
579
- //#region src/core/validation.ts
580
- /** Runtime counterpart to `FieldType`. Exhaustive by construction. */
581
- const FIELD_TYPE_PREDICATES = {
582
- text: (value) => typeof value === "string",
583
- email: (value) => typeof value === "string",
584
- number: (value) => typeof value === "number",
585
- boolean: (value) => typeof value === "boolean",
586
- select: (value) => typeof value === "string",
587
- multiselect: (value) => Array.isArray(value),
588
- json: () => true
589
- };
590
- const EMAIL_PATTERN = /^[\w.!#$%&'*+/=?^`{|}~-]+@[a-zA-Z\d](?:[a-zA-Z\d-]{0,61}[a-zA-Z\d])?(?:\.[a-zA-Z\d](?:[a-zA-Z\d-]{0,61}[a-zA-Z\d])?)*$/;
591
- /**
592
- * Every message a single field's value earns. Empty means valid. Not generic:
593
- * the per-field types live at the instance boundary, and the cast down to
594
- * `JsonValue` happens once, in `initForm`.
595
- */
596
- function validateValue({ field, value, values, pattern, validator }) {
597
- const rules = field.validations;
598
- const messages = [];
599
- const isAbsent = value === void 0 || value === null;
600
- const isBlank = isAbsent || value === "" || Array.isArray(value) && value.length === 0;
601
- if (rules?.required === true && isBlank) messages.push(`${field.label} is required`);
602
- if (isAbsent) return messages;
603
- if (!FIELD_TYPE_PREDICATES[field.type](value)) messages.push(`${field.label} expects ${/^[aeiou]/.test(field.type) ? "an" : "a"} ${field.type} value`);
604
- if (typeof value === "string") {
605
- if (field.type === "email" && value !== "" && !EMAIL_PATTERN.test(value)) messages.push(`${field.label} must be a valid email address`);
606
- if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must be at least ${rules.minLength} characters`);
607
- if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must be at most ${rules.maxLength} characters`);
608
- if (pattern && !pattern.test(value)) messages.push(`${field.label} is not in the expected format`);
609
- }
610
- if (Array.isArray(value)) {
611
- if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must have at least ${rules.minLength} items`);
612
- if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must have at most ${rules.maxLength} items`);
613
- }
614
- if (typeof value === "number") {
615
- if (rules?.min !== void 0 && value < rules.min) messages.push(`${field.label} must be at least ${rules.min}`);
616
- if (rules?.max !== void 0 && value > rules.max) messages.push(`${field.label} must be at most ${rules.max}`);
617
- }
618
- if (rules?.oneOf) {
619
- const encoded = canonicalize(value);
620
- if (!rules.oneOf.some((option) => canonicalize(option) === encoded)) messages.push(`${field.label} must be one of the allowed options`);
621
- }
622
- if (!validator || messages.length > 0) return messages;
623
- return normalizeValidatorResult({
624
- result: validator({
625
- value,
626
- values
627
- }),
628
- field
629
- });
630
- }
631
- function normalizeValidatorResult({ result, field }) {
632
- if (isThenable(result)) throw new ValidatorError(`Validator for "${field.key}" returned a promise; custom validators must be synchronous`);
633
- if (result === null || result === void 0) return [];
634
- if (typeof result === "string") return [result];
635
- if (Array.isArray(result)) return result.filter((entry) => typeof entry === "string");
636
- throw new ValidatorError(`Validator for "${field.key}" returned ${typeof result}; expected a string, an array of strings, or null`);
637
- }
638
- function isThenable(value) {
639
- return typeof value?.then === "function";
640
- }
641
- /**
642
- * JSON encoding with object keys sorted at every depth, so two values compare
643
- * by content rather than by insertion order.
644
- */
645
- function canonicalize(value) {
646
- const walk = (input) => {
647
- if (Array.isArray(input)) return input.map(walk);
648
- if (input !== null && typeof input === "object") return Object.fromEntries(Object.entries(input).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, item]) => [key, walk(item)]));
649
- return input;
650
- };
651
- return JSON.stringify(walk(value));
652
- }
653
- //#endregion
654
958
  //#region src/core/form.ts
655
959
  const REQUIRED_CORE_METHODS = [
656
960
  "getAppUserId",
@@ -685,41 +989,117 @@ function mergeCustomValidations({ schema, customValidations }) {
685
989
  };
686
990
  }
687
991
  /**
688
- * Validates the core instance and the schema map once, then returns a client
689
- * whose `getForm` builds each form by id.
992
+ * Validates the core instance and registered form schemas once, then returns a
993
+ * client whose `getForm` builds each form by id.
690
994
  */
691
995
  function initForms(options) {
692
996
  return createFormsClient(options);
693
997
  }
694
- function createFormsClient({ core, schemas, analyticsInstance, baseUrl, storage, persistence }) {
695
- if (!hasRequiredCoreMethods(core)) throw new FormsError("initForms requires an initialized Embeddables core instance.");
696
- if (typeof schemas !== "object" || schemas === null) throw new SchemaError("initForms requires a schemas map keyed by form id.");
697
- for (const [formId, schema] of Object.entries(schemas)) {
698
- if (typeof schema !== "object" || schema === null) throw new SchemaError(`schemas["${formId}"]: must be a form schema object`);
699
- if (typeof schema.id === "string" && schema.id !== "" && schema.id !== formId) throw new SchemaError(`schemas["${formId}"]: schema.id is "${String(schema.id)}" — it must equal its map key`);
998
+ const REQUIRED_CORE_FORM_METHODS = ["getFormIds", "getFormSchema"];
999
+ function hasCoreFormSchemaMethods(value) {
1000
+ return REQUIRED_CORE_FORM_METHODS.every((method) => typeof value[method] === "function");
1001
+ }
1002
+ function toFormSchema(value, formId) {
1003
+ if (typeof value !== "object" || value === null) throw new SchemaError(`Registered form "${formId}": schema must be an object.`);
1004
+ const schema = value;
1005
+ if (typeof schema.id !== "string" || schema.id === "") throw new SchemaError(`Registered form "${formId}": schema.id must be a non-empty string.`);
1006
+ if (schema.id !== formId) throw new SchemaError(`Registered form "${formId}": schema id must match map key.`);
1007
+ return schema;
1008
+ }
1009
+ function indexOverrideEntries({ entries, label, valueKey }) {
1010
+ if (entries === void 0) return /* @__PURE__ */ new Map();
1011
+ const byFormId = /* @__PURE__ */ new Map();
1012
+ for (let index = 0; index < entries.length; index++) {
1013
+ const entry = entries[index];
1014
+ if (typeof entry !== "object" || entry === null) throw new SchemaError(`${label}[${index}]: must be an object.`);
1015
+ const { formId } = entry;
1016
+ if (typeof formId !== "string" || formId === "") throw new SchemaError(`${label}[${index}].formId: must be a non-empty string.`);
1017
+ const value = entry[valueKey];
1018
+ if (value === void 0) throw new SchemaError(`${label}[${index}].${String(valueKey)}: is required.`);
1019
+ if (byFormId.has(formId)) throw new SchemaError(`${label}: duplicate form id "${formId}".`);
1020
+ byFormId.set(formId, value);
1021
+ }
1022
+ return byFormId;
1023
+ }
1024
+ function resolveInitConfig({ core, customValidations, serverFormData }) {
1025
+ if (!hasCoreFormSchemaMethods(core)) throw new FormsError("initForms requires a Core instance with registered form schemas.");
1026
+ const formIds = core.getFormIds();
1027
+ if (formIds.length === 0) throw new FormsError("initForms requires at least one form schema registered on Core.");
1028
+ const customByFormId = indexOverrideEntries({
1029
+ entries: customValidations,
1030
+ label: "customValidations",
1031
+ valueKey: "customValidations"
1032
+ });
1033
+ const serverDataByFormId = indexOverrideEntries({
1034
+ entries: serverFormData,
1035
+ label: "serverFormData",
1036
+ valueKey: "serverFormData"
1037
+ });
1038
+ const registeredIds = new Set(formIds);
1039
+ for (const formId of customByFormId.keys()) if (!registeredIds.has(formId)) throw new SchemaError(`customValidations: unknown form id "${formId}".`);
1040
+ for (const formId of serverDataByFormId.keys()) if (!registeredIds.has(formId)) throw new SchemaError(`serverFormData: unknown form id "${formId}".`);
1041
+ const registry = /* @__PURE__ */ new Map();
1042
+ for (const formId of formIds) {
1043
+ const schema = toFormSchema(core.getFormSchema(formId), formId);
1044
+ const entryCustomValidations = customByFormId.get(formId);
1045
+ const entryServerFormData = serverDataByFormId.get(formId);
1046
+ mergeCustomValidations({
1047
+ schema,
1048
+ customValidations: entryCustomValidations
1049
+ });
1050
+ registry.set(formId, {
1051
+ schema,
1052
+ customValidations: entryCustomValidations,
1053
+ serverFormData: entryServerFormData
1054
+ });
700
1055
  }
1056
+ return registry;
1057
+ }
1058
+ function createFormsClient({ core, customValidations, serverFormData, analyticsInstance, baseUrl, fetch: fetchImpl, storage, persistence }) {
1059
+ if (!hasRequiredCoreMethods(core)) throw new FormsError("initForms requires an initialized Embeddables core instance.");
1060
+ const registry = resolveInitConfig({
1061
+ core,
1062
+ customValidations,
1063
+ serverFormData
1064
+ });
1065
+ const uploadConfig = resolvePersistenceConfig({
1066
+ core,
1067
+ baseUrl,
1068
+ fetch: fetchImpl
1069
+ });
701
1070
  const resolvedPersistence = persistence ?? resolveDefaultPersistence({
702
1071
  core,
703
- baseUrl
1072
+ baseUrl,
1073
+ fetch: fetchImpl
704
1074
  });
705
1075
  const instances = /* @__PURE__ */ new Map();
706
- return { getForm: ({ formId, customValidations }) => {
1076
+ return { getForm({ formId }) {
707
1077
  const existing = instances.get(formId);
708
1078
  if (existing !== void 0) return existing;
709
- const schema = schemas[formId];
710
- if (schema === void 0) throw new FormsError(`Unknown form id "${formId}".`);
1079
+ const entry = registry.get(formId);
1080
+ if (entry === void 0) throw new FormsError(`Unknown form id "${formId}".`);
711
1081
  const instance = createFormInstance({
712
1082
  analyticsInstance,
713
1083
  storage,
714
1084
  persistence: resolvedPersistence,
715
- schema,
716
- customValidations
1085
+ uploadConfig,
1086
+ schema: entry.schema,
1087
+ customValidations: entry.customValidations,
1088
+ serverFormData: entry.serverFormData,
1089
+ projectId: core.getProjectId()
717
1090
  });
718
1091
  instances.set(formId, instance);
719
1092
  return instance;
720
1093
  } };
721
1094
  }
722
- function createFormInstance({ analyticsInstance, storage, persistence, schema, customValidations }) {
1095
+ function mergeInitialBag({ fromStorage, serverFormData }) {
1096
+ if (serverFormData === void 0) return { ...fromStorage };
1097
+ return {
1098
+ ...fromStorage,
1099
+ ...serverFormData
1100
+ };
1101
+ }
1102
+ function createFormInstance({ analyticsInstance, storage, persistence, uploadConfig, schema, customValidations, serverFormData, projectId }) {
723
1103
  const resolved = resolveForm({ schema: mergeCustomValidations({
724
1104
  schema,
725
1105
  customValidations
@@ -739,11 +1119,14 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
739
1119
  };
740
1120
  const state = {
741
1121
  storage: resolvedStorage,
742
- bag: { ...readFields({
743
- storage: resolvedStorage,
744
- formKey: resolved.formKey,
745
- fieldDefinitions: resolved.fields
746
- }) },
1122
+ bag: mergeInitialBag({
1123
+ fromStorage: readFields({
1124
+ storage: resolvedStorage,
1125
+ formKey: resolved.formKey,
1126
+ fieldDefinitions: resolved.fields
1127
+ }),
1128
+ serverFormData
1129
+ }),
747
1130
  errors: /* @__PURE__ */ new Map()
748
1131
  };
749
1132
  const firePersistence = (run) => {
@@ -814,12 +1197,24 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
814
1197
  }
815
1198
  };
816
1199
  const set = (patch) => {
817
- const changes = patch;
818
- const entries = Object.entries(changes);
819
- if (entries.length === 0) return Promise.resolve({
1200
+ const changes = { ...patch };
1201
+ if (Object.keys(changes).length === 0) return Promise.resolve({
820
1202
  ok: true,
821
1203
  errors: noErrors()
822
1204
  });
1205
+ const previousBag = readBag();
1206
+ for (const [key, value] of Object.entries(changes)) {
1207
+ const field = declared.get(key);
1208
+ if (!field || field.type !== "multiselect" || !field.options) continue;
1209
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) continue;
1210
+ const stored = previousBag[key];
1211
+ changes[key] = [...normalizeExclusiveSelection({
1212
+ next: value,
1213
+ previous: Array.isArray(stored) ? stored : [],
1214
+ options: field.options
1215
+ })];
1216
+ }
1217
+ const entries = Object.entries(changes);
823
1218
  const errors = /* @__PURE__ */ new Map();
824
1219
  for (const [key, value] of entries) {
825
1220
  const field = declared.get(key);
@@ -827,12 +1222,14 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
827
1222
  errors.set(key, [`Unknown field: ${key}`]);
828
1223
  continue;
829
1224
  }
1225
+ if (value === void 0) continue;
830
1226
  if (!isSerializable({ value })) errors.set(key, [`${field.label} value is not JSON-serializable`]);
831
1227
  }
832
1228
  const candidate = {
833
- ...readBag(),
1229
+ ...previousBag,
834
1230
  ...changes
835
1231
  };
1232
+ for (const [key, value] of entries) if (value === void 0) delete candidate[key];
836
1233
  const snapshot = narrow(candidate);
837
1234
  for (const [key, value] of entries) {
838
1235
  const field = declared.get(key);
@@ -866,12 +1263,18 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
866
1263
  } catch {
867
1264
  return Promise.resolve(degradeAndReport({ entries }));
868
1265
  }
1266
+ writeFormCookieData({
1267
+ projectId,
1268
+ formId: resolved.formKey,
1269
+ bag: candidate,
1270
+ fieldDefinitions: resolved.fields
1271
+ });
869
1272
  notify();
870
1273
  const persistedFields = entries.map(([key, value]) => {
871
1274
  const field = declared.get(key);
872
1275
  return {
873
1276
  key,
874
- value,
1277
+ value: value === void 0 ? null : value,
875
1278
  ...field?.registryId === void 0 ? {} : { registryId: field.registryId },
876
1279
  ...field?.protocolFieldId === void 0 ? {} : { protocolFieldId: field.protocolFieldId }
877
1280
  };
@@ -890,10 +1293,12 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
890
1293
  });
891
1294
  return analyticsInstance.trackEvent([buildDataUpdatedEvent({
892
1295
  fields: resolved.fields,
893
- patch: changes
1296
+ patch: changes,
1297
+ formId: schema.id
894
1298
  }), ...buildFieldUpdatedEvents({
895
1299
  fields: resolved.fields,
896
- patch: changes
1300
+ patch: changes,
1301
+ formId: schema.id
897
1302
  })]).then(() => ({
898
1303
  ok: true,
899
1304
  errors: noErrors()
@@ -1058,6 +1463,10 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
1058
1463
  });
1059
1464
  state.bag = {};
1060
1465
  state.errors.clear();
1466
+ clearFormCookieData({
1467
+ projectId,
1468
+ formId: resolved.formKey
1469
+ });
1061
1470
  notify();
1062
1471
  };
1063
1472
  const subscribe = (listener) => {
@@ -1066,6 +1475,27 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
1066
1475
  listeners.delete(listener);
1067
1476
  };
1068
1477
  };
1478
+ const uploadFile = async ({ key, file, fileName }) => {
1479
+ const field = declared.get(key);
1480
+ if (!field) throw new FormsError(`Unknown field: ${key}`);
1481
+ if (field.type !== "file") throw new FormsError(`Field "${key}" is not a file field`);
1482
+ if (!uploadConfig) throw new FormsError("File uploads require a valid publishable key and initialized Embeddables core instance.");
1483
+ const rules = field.validations;
1484
+ const contentType = file.type || "application/octet-stream";
1485
+ const byteLength = file.size;
1486
+ if (byteLength > 26214400) throw new FormsError("Maximum upload size is 25 MiB.");
1487
+ if (rules?.maxSize !== void 0 && byteLength > rules.maxSize) throw new FormsError(`${field.label} must be at most ${rules.maxSize} bytes`);
1488
+ if (rules?.accept !== void 0 && !contentTypeMatchesAccept({
1489
+ contentType,
1490
+ accept: rules.accept
1491
+ })) throw new FormsError(`${field.label} must be one of the allowed file types`);
1492
+ return uploadFormFile(uploadConfig, {
1493
+ formId: resolved.formKey,
1494
+ fieldKey: key,
1495
+ file,
1496
+ fileName
1497
+ });
1498
+ };
1069
1499
  return {
1070
1500
  key: schema.id,
1071
1501
  set,
@@ -1076,10 +1506,11 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
1076
1506
  validate,
1077
1507
  errors: () => freeze(state.errors),
1078
1508
  clear,
1509
+ uploadFile,
1079
1510
  subscribe
1080
1511
  };
1081
1512
  }
1082
1513
  //#endregion
1083
- export { ValidatorError as a, SchemaError as i, FORM_DATA_KEY as n, FormsError as r, initForms as t };
1514
+ export { FORM_DATA_KEY as a, SchemaError as c, seedServerFormsStorageFromCookies as i, ValidatorError as l, initForms as n, createMemoryFormsStorage as o, createNoopPersistence as r, FormsError as s, createFormsClient as t };
1084
1515
 
1085
- //# sourceMappingURL=form-9Tp91g6a.js.map
1516
+ //# sourceMappingURL=form-7wmC3G_q.js.map