@embeddables/forms 0.0.1 → 0.0.3

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.
@@ -0,0 +1,1075 @@
1
+ import { hc } from "hono/client";
2
+ import { isValidPublishableKey } from "@embeddables/core";
3
+ //#region src/errors.ts
4
+ /**
5
+ * Typed error hierarchy. Every failure this SDK raises on its own behalf is an
6
+ * instance of one of these, so consumers branch on the type
7
+ * (`if (e instanceof SchemaError) …`) instead of string-matching messages.
8
+ * Catch `FormsError` to handle them all.
9
+ *
10
+ * An error thrown by a consumer's own custom validator is never wrapped in one
11
+ * of these — it propagates with its original type and stack.
12
+ */
13
+ /** Base class for every error the SDK throws. */
14
+ var FormsError = class extends Error {
15
+ constructor(message, options) {
16
+ super(message, options);
17
+ this.name = "FormsError";
18
+ }
19
+ };
20
+ /** The schema is malformed. */
21
+ var SchemaError = class extends FormsError {
22
+ constructor(message, options) {
23
+ super(message, options);
24
+ this.name = "SchemaError";
25
+ }
26
+ };
27
+ /** A custom validator returned a thenable, or a shape that is not a message. */
28
+ var ValidatorError = class extends FormsError {
29
+ constructor(message, options) {
30
+ super(message, options);
31
+ this.name = "ValidatorError";
32
+ }
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(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
+ };
57
+ }
58
+ //#endregion
59
+ //#region src/storage/persistence.ts
60
+ /**
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.
63
+ */
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);
91
+ }
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
+ }
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 }
108
+ });
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
+ }
130
+ function resolveDefaultPersistence(config) {
131
+ const resolved = resolvePersistenceConfig(config);
132
+ if (!resolved) return createNoopPersistence();
133
+ return createApiPersistence(resolved);
134
+ }
135
+ //#endregion
136
+ //#region src/storage/storage.ts
137
+ /** Every form on the origin shares this one entry, indexed by form ID. */
138
+ const FORM_DATA_KEY = "EMBEDDABLES-FORM-DATA";
139
+ const FIELD_TYPES$1 = [
140
+ "text",
141
+ "email",
142
+ "number",
143
+ "boolean",
144
+ "select",
145
+ "multiselect",
146
+ "json"
147
+ ];
148
+ const LIVE_DOCUMENTS = /* @__PURE__ */ new WeakMap();
149
+ function readDocumentFromStorage({ storage }) {
150
+ try {
151
+ const raw = storage.getItem(FORM_DATA_KEY);
152
+ if (raw === null) return {};
153
+ const parsed = JSON.parse(raw);
154
+ if (!isObjectLike$1(parsed)) return {};
155
+ return parsed;
156
+ } catch {
157
+ return {};
158
+ }
159
+ }
160
+ /**
161
+ * Load once per storage object; later calls reuse the in-memory document.
162
+ *
163
+ * There is no invalidation: a write from another tab or a user clearing site
164
+ * data is never picked up, and the next write here overwrites it. Recovering
165
+ * from an external mutation means constructing a new storage object.
166
+ */
167
+ function loadDocument({ storage }) {
168
+ const cached = LIVE_DOCUMENTS.get(storage);
169
+ if (cached) return cached;
170
+ const document = readDocumentFromStorage({ storage });
171
+ LIVE_DOCUMENTS.set(storage, document);
172
+ return document;
173
+ }
174
+ function resolveStorage({ storage }) {
175
+ if (storage) return storage;
176
+ try {
177
+ const candidate = globalThis.localStorage;
178
+ candidate.getItem(FORM_DATA_KEY);
179
+ return candidate;
180
+ } catch {
181
+ return createMemoryStorage();
182
+ }
183
+ }
184
+ /**
185
+ * A fresh in-memory shim seeded with the document as last read, for an instance
186
+ * whose real storage started throwing mid-session.
187
+ */
188
+ function degradeToMemory({ storage }) {
189
+ const shim = createMemoryStorage();
190
+ const snapshot = { ...LIVE_DOCUMENTS.get(storage) ?? readDocumentFromStorage({ storage }) };
191
+ shim.setItem(FORM_DATA_KEY, JSON.stringify(snapshot));
192
+ LIVE_DOCUMENTS.set(shim, snapshot);
193
+ return shim;
194
+ }
195
+ function readFields({ storage, formKey, fieldDefinitions }) {
196
+ const bag = loadDocument({ storage })[formKey];
197
+ if (!isObjectLike$1(bag)) return {};
198
+ const declared = new Set(fieldDefinitions.map((field) => field.key));
199
+ const values = {};
200
+ for (const [key, entry] of Object.entries(bag)) {
201
+ if (!declared.has(key)) continue;
202
+ if (isStoredField(entry)) values[key] = entry.value;
203
+ }
204
+ return values;
205
+ }
206
+ function writeFields({ storage, formKey, fields, fieldDefinitions }) {
207
+ const current = loadDocument({ storage });
208
+ const currentForm = isObjectLike$1(current[formKey]) ? current[formKey] : {};
209
+ const declaredKeys = new Set(fieldDefinitions.map((field) => field.key));
210
+ const nextForm = {};
211
+ for (const [key, entry] of Object.entries(currentForm)) if (!declaredKeys.has(key)) nextForm[key] = entry;
212
+ for (const field of fieldDefinitions) {
213
+ const value = fields[field.key];
214
+ if (value === void 0) continue;
215
+ nextForm[field.key] = {
216
+ value,
217
+ type: field.type,
218
+ label: field.label,
219
+ ...field.registryId === void 0 ? {} : { registryId: field.registryId },
220
+ ...field.protocolFieldId === void 0 ? {} : { protocolFieldId: field.protocolFieldId }
221
+ };
222
+ }
223
+ const next = {
224
+ ...current,
225
+ [formKey]: nextForm
226
+ };
227
+ storage.setItem(FORM_DATA_KEY, JSON.stringify(next));
228
+ LIVE_DOCUMENTS.set(storage, next);
229
+ }
230
+ function removeFields({ storage, formKey }) {
231
+ const { [formKey]: _dropped, ...rest } = loadDocument({ storage });
232
+ if (Object.keys(rest).length === 0) {
233
+ storage.removeItem(FORM_DATA_KEY);
234
+ LIVE_DOCUMENTS.set(storage, rest);
235
+ return;
236
+ }
237
+ storage.setItem(FORM_DATA_KEY, JSON.stringify(rest));
238
+ LIVE_DOCUMENTS.set(storage, rest);
239
+ }
240
+ /**
241
+ * Whether a value survives `JSON.stringify`. `undefined`, a function, and a
242
+ * `Symbol` make it return `undefined`; a circular reference and a `BigInt` make
243
+ * it throw. All five must be rejected before a write, because a value that
244
+ * cannot stringify aborts a write carrying every form's data.
245
+ */
246
+ function isSerializable({ value }) {
247
+ try {
248
+ return typeof JSON.stringify(value) === "string";
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+ function createMemoryStorage() {
254
+ const entries = /* @__PURE__ */ new Map();
255
+ return {
256
+ getItem: (key) => entries.get(key) ?? null,
257
+ setItem: (key, value) => {
258
+ entries.set(key, value);
259
+ },
260
+ removeItem: (key) => {
261
+ entries.delete(key);
262
+ }
263
+ };
264
+ }
265
+ function isObjectLike$1(value) {
266
+ return typeof value === "object" && value !== null && !Array.isArray(value);
267
+ }
268
+ function isStoredField(value) {
269
+ if (!isObjectLike$1(value) || !Object.hasOwn(value, "value")) return false;
270
+ if (!isSerializable({ value: value["value"] })) return false;
271
+ if (typeof value["type"] !== "string" || !FIELD_TYPES$1.includes(value["type"]) || typeof value["label"] !== "string") return false;
272
+ if (value["registryId"] !== void 0 && typeof value["registryId"] !== "string") return false;
273
+ if (value["protocolFieldId"] !== void 0 && typeof value["protocolFieldId"] !== "string") return false;
274
+ return true;
275
+ }
276
+ //#endregion
277
+ //#region src/core/analytics.ts
278
+ /** The ingest bound on a `data:updated` entry's `value`. */
279
+ const MAX_VALUE_LENGTH = 1024;
280
+ /** The ingest bound on a `data:updated` entry's `label`. */
281
+ const MAX_LABEL_LENGTH = 256;
282
+ /** Maps a form field's declared type to the analytics `field:updated` class. */
283
+ function mapFieldUpdatedType(type) {
284
+ return type;
285
+ }
286
+ /**
287
+ * Stringifies values for `data:updated` entries. `field:updated` carries the
288
+ * raw `field_value`; only the batch event caps and stringifies for ingest.
289
+ */
290
+ function formatFieldValue({ value }) {
291
+ return (typeof value === "string" ? value : JSON.stringify(value)).slice(0, MAX_VALUE_LENGTH);
292
+ }
293
+ /** One event carrying every key in one `.set()` call. */
294
+ function buildDataUpdatedEvent({ fields, patch }) {
295
+ const byKey = new Map(fields.map((field) => [field.key, field]));
296
+ return {
297
+ event_name: "data:updated",
298
+ data: Object.fromEntries(Object.entries(patch).map(([key, value]) => {
299
+ const field = byKey.get(key);
300
+ return [key, {
301
+ value: formatFieldValue({
302
+ value,
303
+ field
304
+ }),
305
+ label: (field?.label ?? key).slice(0, MAX_LABEL_LENGTH)
306
+ }];
307
+ }))
308
+ };
309
+ }
310
+ /** One `field:updated` per changed key, emitted alongside `data:updated`. */
311
+ function buildFieldUpdatedEvents({ fields, patch }) {
312
+ const byKey = new Map(fields.map((field) => [field.key, field]));
313
+ return Object.entries(patch).map(([key, value]) => {
314
+ const field = byKey.get(key);
315
+ const event = {
316
+ event_name: "field:updated",
317
+ field_key: key,
318
+ field_type: mapFieldUpdatedType(field?.type ?? "text"),
319
+ field_value: value
320
+ };
321
+ if (field?.registryId !== void 0) event.registry_field_id = field.registryId;
322
+ if (field?.protocolFieldId !== void 0) event.protocol_field_id = field.protocolFieldId;
323
+ return event;
324
+ });
325
+ }
326
+ //#endregion
327
+ //#region src/core/resolve.ts
328
+ const FIELD_TYPES = [
329
+ "text",
330
+ "email",
331
+ "number",
332
+ "boolean",
333
+ "select",
334
+ "multiselect",
335
+ "json"
336
+ ];
337
+ const VALIDATION_RULES = [
338
+ "required",
339
+ "minLength",
340
+ "maxLength",
341
+ "min",
342
+ "max",
343
+ "pattern",
344
+ "patternFlags",
345
+ "oneOf",
346
+ "custom"
347
+ ];
348
+ const NUMERIC_RULES = [
349
+ "minLength",
350
+ "maxLength",
351
+ "min",
352
+ "max"
353
+ ];
354
+ /** The ingest `z.string().max(128)` bound on a `data:updated` key. */
355
+ const MAX_FIELD_KEY_LENGTH = 128;
356
+ /** The ingest `z.string().max(128)` bound on a `form:submitted` key. */
357
+ const MAX_FORM_KEY_LENGTH = 128;
358
+ const VALID_PATTERN_FLAGS = /^[dgimsuvy]*$/;
359
+ const RESOLVED_SCHEMAS = /* @__PURE__ */ new WeakMap();
360
+ function resolveForm({ schema }) {
361
+ const memoized = RESOLVED_SCHEMAS.get(schema);
362
+ if (memoized) return memoized;
363
+ const resolved = validateAndCompile({ schema });
364
+ RESOLVED_SCHEMAS.set(schema, resolved);
365
+ return resolved;
366
+ }
367
+ function validateAndCompile({ schema }) {
368
+ const root = schema;
369
+ assertJsonRepresentable({ root });
370
+ if (!isObjectLike(root)) throw new SchemaError("schema: must be an object");
371
+ const formKey = root["id"];
372
+ if (typeof formKey !== "string" || formKey.trim() === "") throw new SchemaError("schema.id: must be a non-empty string");
373
+ if (formKey !== formKey.trim()) throw new SchemaError("schema.id: must not have leading or trailing whitespace");
374
+ if (formKey.length > MAX_FORM_KEY_LENGTH) throw new SchemaError(`schema.id: must be at most ${MAX_FORM_KEY_LENGTH} characters (received ${formKey.length})`);
375
+ const name = root["name"];
376
+ if (name !== void 0) {
377
+ if (typeof name !== "string") throw new SchemaError("schema.name: must be a string");
378
+ if (name.trim() === "") throw new SchemaError("schema.name: must be a non-empty string");
379
+ }
380
+ if (!Array.isArray(root["fields"])) throw new SchemaError("schema.fields: must be an array");
381
+ const fields = root["fields"];
382
+ if (fields.length === 0) throw new SchemaError("schema.fields: must declare at least one field");
383
+ const patterns = /* @__PURE__ */ new Map();
384
+ const seenKeys = /* @__PURE__ */ new Set();
385
+ fields.forEach((field, index) => {
386
+ validateField({
387
+ field,
388
+ path: `schema.fields[${index}]`,
389
+ seenKeys,
390
+ patterns
391
+ });
392
+ });
393
+ return {
394
+ formKey,
395
+ fields: [...fields],
396
+ patterns
397
+ };
398
+ }
399
+ function validateField({ field, path, seenKeys, patterns }) {
400
+ if (!isObjectLike(field)) throw new SchemaError(`${path}: must be an object`);
401
+ const key = field["key"];
402
+ if (typeof key !== "string" || key.trim() === "") throw new SchemaError(`${path}.key: must be a non-empty string`);
403
+ if (key.length > MAX_FIELD_KEY_LENGTH) throw new SchemaError(`${path}.key: must be at most ${MAX_FIELD_KEY_LENGTH} characters (received ${key.length})`);
404
+ if (seenKeys.has(key)) throw new SchemaError(`${path}.key: duplicate field key "${key}" in this form`);
405
+ seenKeys.add(key);
406
+ const label = field["label"];
407
+ if (typeof label !== "string" || label.trim() === "") throw new SchemaError(`${path}.label: must be a non-empty string`);
408
+ const type = field["type"];
409
+ if (typeof type !== "string" || !FIELD_TYPES.includes(type)) throw new SchemaError(`${path}.type: must be one of ${FIELD_TYPES.join(", ")}`);
410
+ const registryId = field["registryId"];
411
+ if (registryId !== void 0 && typeof registryId !== "string") throw new SchemaError(`${path}.registryId: must be a string`);
412
+ const protocolFieldId = field["protocolFieldId"];
413
+ if (protocolFieldId !== void 0 && typeof protocolFieldId !== "string") throw new SchemaError(`${path}.protocolFieldId: must be a string`);
414
+ const validations = field["validations"];
415
+ if (validations === void 0) return;
416
+ validateValidations({
417
+ validations,
418
+ path: `${path}.validations`
419
+ });
420
+ if (!isObjectLike(validations)) return;
421
+ const pattern = validations["pattern"];
422
+ if (typeof pattern !== "string") return;
423
+ const flags = validations["patternFlags"];
424
+ patterns.set(key, compilePattern({
425
+ pattern,
426
+ flags: typeof flags === "string" ? flags : "",
427
+ path: `${path}.validations.pattern`
428
+ }));
429
+ }
430
+ function validateValidations({ validations, path }) {
431
+ 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(", ")}`);
433
+ const required = validations["required"];
434
+ if (required !== void 0 && typeof required !== "boolean") throw new SchemaError(`${path}.required: must be a boolean`);
435
+ for (const rule of NUMERIC_RULES) {
436
+ const value = validations[rule];
437
+ if (value !== void 0 && !(typeof value === "number" && Number.isFinite(value))) throw new SchemaError(`${path}.${rule}: must be a finite number`);
438
+ }
439
+ const minLength = validations["minLength"];
440
+ const maxLength = validations["maxLength"];
441
+ if (typeof minLength === "number" && typeof maxLength === "number" && maxLength < minLength) throw new SchemaError(`${path}.maxLength: must be greater than or equal to minLength`);
442
+ const min = validations["min"];
443
+ const max = validations["max"];
444
+ if (typeof min === "number" && typeof max === "number" && max < min) throw new SchemaError(`${path}.max: must be greater than or equal to min`);
445
+ const oneOf = validations["oneOf"];
446
+ if (oneOf !== void 0 && !(Array.isArray(oneOf) && oneOf.length > 0)) throw new SchemaError(`${path}.oneOf: must be a non-empty array`);
447
+ const pattern = validations["pattern"];
448
+ 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
+ const custom = validations["custom"];
452
+ if (custom !== void 0 && typeof custom !== "function") throw new SchemaError(`${path}.custom: must be a function (received ${typeof custom})`);
453
+ }
454
+ function compilePattern({ pattern, flags, path }) {
455
+ try {
456
+ return new RegExp(pattern, flags.replace(/[gy]/g, ""));
457
+ } catch (error) {
458
+ throw new SchemaError(`${path}: invalid pattern — ${errorMessage(error)}`, { cause: error });
459
+ }
460
+ }
461
+ /**
462
+ * Rejects every value in the config that would not survive
463
+ * `JSON.parse(JSON.stringify(x))` — a function included, except at the one
464
+ * permitted location, `validations.custom` on a field.
465
+ */
466
+ function assertJsonRepresentable({ root }) {
467
+ const stripped = withoutFieldValidators(root);
468
+ const serialized = stringifyOrUndefined(stripped);
469
+ if (serialized === void 0) throw new SchemaError(`${findNonJsonPath({
470
+ value: stripped,
471
+ path: "schema",
472
+ seen: /* @__PURE__ */ new Set()
473
+ }) ?? "schema"}: value cannot be serialized to JSON`);
474
+ const mismatch = firstMismatch({
475
+ actual: stripped,
476
+ expected: JSON.parse(serialized),
477
+ path: "schema"
478
+ });
479
+ if (mismatch) throw new SchemaError(`${mismatch}: value does not survive a JSON round trip; only a field's validations.custom may hold a function, and every other value must be JSON-representable`);
480
+ }
481
+ function withoutFieldValidators(root) {
482
+ if (!isObjectLike(root)) return root;
483
+ if (!Array.isArray(root["fields"])) return root;
484
+ const fields = root["fields"];
485
+ return {
486
+ ...root,
487
+ fields: fields.map(stripField)
488
+ };
489
+ }
490
+ function stripField(field) {
491
+ if (!isObjectLike(field)) return field;
492
+ const validations = field["validations"];
493
+ if (!isObjectLike(validations) || !("custom" in validations)) return field;
494
+ const { custom: _custom, ...rest } = validations;
495
+ return {
496
+ ...field,
497
+ validations: rest
498
+ };
499
+ }
500
+ function stringifyOrUndefined(value) {
501
+ try {
502
+ return JSON.stringify(value);
503
+ } catch {
504
+ return;
505
+ }
506
+ }
507
+ /** The path of the first value `JSON.stringify` cannot handle at all. */
508
+ function findNonJsonPath({ value, path, seen }) {
509
+ if (typeof value === "bigint" || typeof value === "symbol") return path;
510
+ if (value === null || typeof value !== "object") return void 0;
511
+ if (seen.has(value)) return path;
512
+ seen.add(value);
513
+ for (const [childPath, child] of childEntries({
514
+ value,
515
+ path
516
+ })) {
517
+ const found = findNonJsonPath({
518
+ value: child,
519
+ path: childPath,
520
+ seen
521
+ });
522
+ if (found) return found;
523
+ }
524
+ seen.delete(value);
525
+ }
526
+ function childEntries({ value, path }) {
527
+ if (Array.isArray(value)) return value.map((item, index) => [`${path}[${index}]`, item]);
528
+ return Object.entries(value).map(([key, item]) => [`${path}.${key}`, item]);
529
+ }
530
+ /** The path of the first value that changed across the round trip. */
531
+ function firstMismatch({ actual, expected, path }) {
532
+ if (Array.isArray(actual) || Array.isArray(expected)) {
533
+ if (!Array.isArray(actual) || !Array.isArray(expected)) return path;
534
+ const actualItems = actual;
535
+ const expectedItems = expected;
536
+ if (actualItems.length !== expectedItems.length) return path;
537
+ for (const [index, item] of actualItems.entries()) {
538
+ const found = firstMismatch({
539
+ actual: item,
540
+ expected: expectedItems[index],
541
+ path: `${path}[${index}]`
542
+ });
543
+ if (found) return found;
544
+ }
545
+ return;
546
+ }
547
+ if (isJsonObject(actual) && isJsonObject(expected)) {
548
+ const actualKeys = Object.keys(actual);
549
+ const expectedKeys = Object.keys(expected);
550
+ if (actualKeys.length !== expectedKeys.length) {
551
+ const dropped = actualKeys.find((key) => !expectedKeys.includes(key));
552
+ return dropped === void 0 ? path : `${path}.${dropped}`;
553
+ }
554
+ for (const key of actualKeys) {
555
+ const found = firstMismatch({
556
+ actual: actual[key],
557
+ expected: expected[key],
558
+ path: `${path}.${key}`
559
+ });
560
+ if (found) return found;
561
+ }
562
+ return;
563
+ }
564
+ return actual === expected ? void 0 : path;
565
+ }
566
+ function isObjectLike(value) {
567
+ return typeof value === "object" && value !== null && !Array.isArray(value);
568
+ }
569
+ /** Narrower than `isObjectLike`: a `Date`, `RegExp`, or class instance is not one. */
570
+ function isJsonObject(value) {
571
+ if (!isObjectLike(value)) return false;
572
+ const prototype = Object.getPrototypeOf(value);
573
+ return prototype === Object.prototype || prototype === null;
574
+ }
575
+ function errorMessage(error) {
576
+ return error instanceof Error ? error.message : String(error);
577
+ }
578
+ //#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
+ //#region src/core/form.ts
655
+ const REQUIRED_CORE_METHODS = [
656
+ "getAppUserId",
657
+ "getProjectId",
658
+ "getPublishableKey"
659
+ ];
660
+ function hasRequiredCoreMethods(value) {
661
+ if (typeof value !== "object" || value === null) return false;
662
+ return REQUIRED_CORE_METHODS.every((method) => typeof value[method] === "function");
663
+ }
664
+ function mergeCustomValidations({ schema, customValidations }) {
665
+ if (!customValidations) return schema;
666
+ const declared = new Set(schema.fields.map((field) => field.key));
667
+ for (const key of Object.keys(customValidations)) {
668
+ if (!declared.has(key)) throw new SchemaError(`customValidations: unknown field key "${key}"`);
669
+ if (typeof customValidations[key] !== "function") throw new SchemaError(`customValidations.${key}: must be a function`);
670
+ }
671
+ const fields = schema.fields.map((field) => {
672
+ const custom = customValidations[field.key];
673
+ if (!custom) return field;
674
+ return {
675
+ ...field,
676
+ validations: {
677
+ ...field.validations,
678
+ custom
679
+ }
680
+ };
681
+ });
682
+ return {
683
+ ...schema,
684
+ fields
685
+ };
686
+ }
687
+ /**
688
+ * Validates the core instance once, then returns a per-form `initForm`. Public
689
+ * API — the Miro signature. Consumers pass only `core` and, optionally, an
690
+ * analytics client.
691
+ */
692
+ function initForms({ core, analyticsInstance }) {
693
+ return createFormsClient({
694
+ core,
695
+ analyticsInstance
696
+ });
697
+ }
698
+ function createFormsClient({ core, analyticsInstance, baseUrl, storage, persistence }) {
699
+ if (!hasRequiredCoreMethods(core)) throw new FormsError("initForms requires an initialized Embeddables core instance.");
700
+ const resolvedPersistence = persistence ?? resolveDefaultPersistence({
701
+ core,
702
+ baseUrl
703
+ });
704
+ return { initForm: (options) => createFormInstance({
705
+ analyticsInstance,
706
+ storage,
707
+ persistence: resolvedPersistence,
708
+ schema: options.schema,
709
+ customValidations: options.customValidations
710
+ }) };
711
+ }
712
+ function createFormInstance({ analyticsInstance, storage, persistence, schema, customValidations }) {
713
+ const resolved = resolveForm({ schema: mergeCustomValidations({
714
+ schema,
715
+ customValidations
716
+ }) });
717
+ const declared = new Map(resolved.fields.map((field) => [field.key, field]));
718
+ const keyByProtocolFieldId = /* @__PURE__ */ new Map();
719
+ for (const field of resolved.fields) {
720
+ const protocolId = field.protocolFieldId;
721
+ if (typeof protocolId === "string" && protocolId.length > 0) keyByProtocolFieldId.set(protocolId, field.key);
722
+ }
723
+ const resolvedStorage = resolveStorage({ storage });
724
+ const listeners = /* @__PURE__ */ new Set();
725
+ const notify = () => {
726
+ for (const listener of listeners) try {
727
+ listener();
728
+ } catch {}
729
+ };
730
+ const state = {
731
+ storage: resolvedStorage,
732
+ bag: { ...readFields({
733
+ storage: resolvedStorage,
734
+ formKey: resolved.formKey,
735
+ fieldDefinitions: resolved.fields
736
+ }) },
737
+ errors: /* @__PURE__ */ new Map()
738
+ };
739
+ const firePersistence = (run) => {
740
+ try {
741
+ const result = run();
742
+ if (result instanceof Promise) result.then(void 0, () => void 0);
743
+ } catch {}
744
+ };
745
+ const mergeRecovered = (recovered) => {
746
+ let mergedCount = 0;
747
+ for (const [key, value] of Object.entries(recovered)) if (value !== void 0 && state.bag[key] === void 0) {
748
+ state.bag[key] = value;
749
+ mergedCount++;
750
+ }
751
+ if (mergedCount > 0) notify();
752
+ };
753
+ const recoverable = resolved.fields.filter((field) => (field.registryId !== void 0 || field.protocolFieldId !== void 0) && state.bag[field.key] === void 0).map((field) => ({
754
+ key: field.key,
755
+ ...field.registryId === void 0 ? {} : { registryId: field.registryId },
756
+ ...field.protocolFieldId === void 0 ? {} : { protocolFieldId: field.protocolFieldId }
757
+ }));
758
+ if (recoverable.length > 0) try {
759
+ const result = persistence.recoverRegistryFields({
760
+ formKey: resolved.formKey,
761
+ fields: recoverable
762
+ });
763
+ if (result instanceof Promise) result.then((recovered) => mergeRecovered(recovered ?? {}), () => void 0);
764
+ else mergeRecovered(result);
765
+ } catch {}
766
+ const freeze = (entries) => Object.freeze(Object.fromEntries(entries));
767
+ const noErrors = () => freeze(/* @__PURE__ */ new Map());
768
+ /** The stored bag narrowed to the keys the config declares. */
769
+ const narrow = (bag) => {
770
+ const narrowed = {};
771
+ for (const field of resolved.fields) {
772
+ const value = bag[field.key];
773
+ if (value !== void 0) narrowed[field.key] = value;
774
+ }
775
+ return narrowed;
776
+ };
777
+ const readBag = () => state.bag;
778
+ const validatorFor = (field) => field.validations?.custom;
779
+ const validateDeclaredFields = ({ snapshot, keys }) => {
780
+ const errors = /* @__PURE__ */ new Map();
781
+ for (const key of keys) {
782
+ const field = declared.get(key);
783
+ if (!field) continue;
784
+ const messages = validateValue({
785
+ field,
786
+ value: snapshot[field.key],
787
+ values: snapshot,
788
+ pattern: resolved.patterns.get(key),
789
+ validator: validatorFor(field)
790
+ });
791
+ if (messages.length > 0) errors.set(key, messages);
792
+ }
793
+ return errors;
794
+ };
795
+ const replaceErrors = (errors) => {
796
+ state.errors.clear();
797
+ for (const [key, messages] of errors) state.errors.set(key, messages);
798
+ };
799
+ const applyPatchValidationErrors = ({ errors, patchKeys }) => {
800
+ for (const key of patchKeys) {
801
+ const messages = errors.get(key);
802
+ if (messages) state.errors.set(key, messages);
803
+ else state.errors.delete(key);
804
+ }
805
+ };
806
+ const set = (patch) => {
807
+ const changes = patch;
808
+ const entries = Object.entries(changes);
809
+ if (entries.length === 0) return Promise.resolve({
810
+ ok: true,
811
+ errors: noErrors()
812
+ });
813
+ const errors = /* @__PURE__ */ new Map();
814
+ for (const [key, value] of entries) {
815
+ const field = declared.get(key);
816
+ if (!field) {
817
+ errors.set(key, [`Unknown field: ${key}`]);
818
+ continue;
819
+ }
820
+ if (!isSerializable({ value })) errors.set(key, [`${field.label} value is not JSON-serializable`]);
821
+ }
822
+ const candidate = {
823
+ ...readBag(),
824
+ ...changes
825
+ };
826
+ const snapshot = narrow(candidate);
827
+ for (const [key, value] of entries) {
828
+ const field = declared.get(key);
829
+ if (!field || errors.has(key)) continue;
830
+ const messages = validateValue({
831
+ field,
832
+ value,
833
+ values: snapshot,
834
+ pattern: resolved.patterns.get(key),
835
+ validator: validatorFor(field)
836
+ });
837
+ if (messages.length > 0) errors.set(key, messages);
838
+ }
839
+ if (errors.size > 0) {
840
+ for (const [key, messages] of errors) state.errors.set(key, messages);
841
+ notify();
842
+ return Promise.resolve({
843
+ ok: false,
844
+ errors: freeze(errors)
845
+ });
846
+ }
847
+ for (const [key] of entries) state.errors.delete(key);
848
+ try {
849
+ writeFields({
850
+ storage: state.storage,
851
+ formKey: resolved.formKey,
852
+ fields: candidate,
853
+ fieldDefinitions: resolved.fields
854
+ });
855
+ state.bag = candidate;
856
+ } catch {
857
+ return Promise.resolve(degradeAndReport({ entries }));
858
+ }
859
+ notify();
860
+ const persistedFields = entries.map(([key, value]) => {
861
+ const field = declared.get(key);
862
+ return {
863
+ key,
864
+ value,
865
+ ...field?.registryId === void 0 ? {} : { registryId: field.registryId },
866
+ ...field?.protocolFieldId === void 0 ? {} : { protocolFieldId: field.protocolFieldId }
867
+ };
868
+ });
869
+ firePersistence(() => persistence.savePartial({
870
+ formKey: resolved.formKey,
871
+ values: snapshot
872
+ }));
873
+ firePersistence(() => persistence.saveFields({
874
+ formKey: resolved.formKey,
875
+ fields: persistedFields
876
+ }));
877
+ if (!analyticsInstance) return Promise.resolve({
878
+ ok: true,
879
+ errors: noErrors()
880
+ });
881
+ return analyticsInstance.trackEvent([buildDataUpdatedEvent({
882
+ fields: resolved.fields,
883
+ patch: changes
884
+ }), ...buildFieldUpdatedEvents({
885
+ fields: resolved.fields,
886
+ patch: changes
887
+ })]).then(() => ({
888
+ ok: true,
889
+ errors: noErrors()
890
+ })).catch((error) => ({
891
+ ok: true,
892
+ errors: noErrors(),
893
+ trackError: error
894
+ }));
895
+ };
896
+ const degradeAndReport = ({ entries }) => {
897
+ state.storage = degradeToMemory({ storage: state.storage });
898
+ const errors = /* @__PURE__ */ new Map();
899
+ for (const [key] of entries) {
900
+ const message = `${declared.get(key)?.label ?? key} could not be persisted; this form is now in-memory only`;
901
+ errors.set(key, [message]);
902
+ state.errors.set(key, [message]);
903
+ }
904
+ notify();
905
+ return {
906
+ ok: false,
907
+ errors: freeze(errors)
908
+ };
909
+ };
910
+ const get = (key) => {
911
+ const fieldKey = key;
912
+ if (!declared.has(fieldKey)) return void 0;
913
+ return state.bag[fieldKey];
914
+ };
915
+ const getValueByProtocolFieldId = ((protocolFieldId) => {
916
+ const fieldKey = keyByProtocolFieldId.get(protocolFieldId);
917
+ if (fieldKey === void 0) return void 0;
918
+ return get(fieldKey);
919
+ });
920
+ const getAll = () => narrow(state.bag);
921
+ const submit = () => {
922
+ const snapshot = narrow(readBag());
923
+ const values = snapshot;
924
+ const errors = validateDeclaredFields({
925
+ snapshot,
926
+ keys: resolved.fields.map((field) => field.key)
927
+ });
928
+ if (errors.size > 0) {
929
+ replaceErrors(errors);
930
+ notify();
931
+ return Promise.resolve({
932
+ ok: false,
933
+ errors: freeze(errors),
934
+ values
935
+ });
936
+ }
937
+ state.errors.clear();
938
+ notify();
939
+ firePersistence(() => persistence.saveSubmission({
940
+ formKey: resolved.formKey,
941
+ values: snapshot
942
+ }));
943
+ if (!analyticsInstance) return Promise.resolve({
944
+ ok: true,
945
+ errors: noErrors(),
946
+ values
947
+ });
948
+ return analyticsInstance.trackEvent([{
949
+ event_name: "form:submitted",
950
+ form_key: resolved.formKey
951
+ }]).then(() => ({
952
+ ok: true,
953
+ errors: noErrors(),
954
+ values
955
+ })).catch((error) => ({
956
+ ok: true,
957
+ errors: noErrors(),
958
+ values,
959
+ trackError: error
960
+ }));
961
+ };
962
+ const validate = (patch) => {
963
+ if (patch === void 0) {
964
+ const snapshot = narrow(readBag());
965
+ const values = snapshot;
966
+ const errors = validateDeclaredFields({
967
+ snapshot,
968
+ keys: resolved.fields.map((field) => field.key)
969
+ });
970
+ if (errors.size > 0) {
971
+ replaceErrors(errors);
972
+ notify();
973
+ return Promise.resolve({
974
+ ok: false,
975
+ errors: freeze(errors),
976
+ values
977
+ });
978
+ }
979
+ state.errors.clear();
980
+ notify();
981
+ return Promise.resolve({
982
+ ok: true,
983
+ errors: noErrors(),
984
+ values
985
+ });
986
+ }
987
+ const changes = patch;
988
+ const entries = Object.entries(changes);
989
+ const values = narrow({
990
+ ...readBag(),
991
+ ...changes
992
+ });
993
+ if (entries.length === 0) return Promise.resolve({
994
+ ok: true,
995
+ errors: noErrors(),
996
+ values
997
+ });
998
+ const errors = /* @__PURE__ */ new Map();
999
+ const patchKeys = entries.map(([key]) => key);
1000
+ for (const [key, value] of entries) {
1001
+ const field = declared.get(key);
1002
+ if (!field) {
1003
+ errors.set(key, [`Unknown field: ${key}`]);
1004
+ continue;
1005
+ }
1006
+ if (!isSerializable({ value })) errors.set(key, [`${field.label} value is not JSON-serializable`]);
1007
+ }
1008
+ const snapshot = narrow({
1009
+ ...readBag(),
1010
+ ...changes
1011
+ });
1012
+ for (const [key] of entries) {
1013
+ if (errors.has(key)) continue;
1014
+ const field = declared.get(key);
1015
+ if (!field) continue;
1016
+ const messages = validateValue({
1017
+ field,
1018
+ value: snapshot[key],
1019
+ values: snapshot,
1020
+ pattern: resolved.patterns.get(key),
1021
+ validator: validatorFor(field)
1022
+ });
1023
+ if (messages.length > 0) errors.set(key, messages);
1024
+ }
1025
+ applyPatchValidationErrors({
1026
+ errors,
1027
+ patchKeys
1028
+ });
1029
+ if (errors.size > 0) {
1030
+ notify();
1031
+ return Promise.resolve({
1032
+ ok: false,
1033
+ errors: freeze(errors),
1034
+ values
1035
+ });
1036
+ }
1037
+ notify();
1038
+ return Promise.resolve({
1039
+ ok: true,
1040
+ errors: noErrors(),
1041
+ values
1042
+ });
1043
+ };
1044
+ const clear = () => {
1045
+ removeFields({
1046
+ storage: state.storage,
1047
+ formKey: resolved.formKey
1048
+ });
1049
+ state.bag = {};
1050
+ state.errors.clear();
1051
+ notify();
1052
+ };
1053
+ const subscribe = (listener) => {
1054
+ listeners.add(listener);
1055
+ return () => {
1056
+ listeners.delete(listener);
1057
+ };
1058
+ };
1059
+ return {
1060
+ key: schema.id,
1061
+ set,
1062
+ get,
1063
+ getValueByProtocolFieldId,
1064
+ getAll,
1065
+ submit,
1066
+ validate,
1067
+ errors: () => freeze(state.errors),
1068
+ clear,
1069
+ subscribe
1070
+ };
1071
+ }
1072
+ //#endregion
1073
+ export { ValidatorError as a, SchemaError as i, FORM_DATA_KEY as n, FormsError as r, initForms as t };
1074
+
1075
+ //# sourceMappingURL=form-CUYofmuL.js.map