@embeddables/forms 0.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,989 @@
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
+ //#endregion
35
+ //#region src/analytics.ts
36
+ /** The ingest bound on a `data:updated` entry's `value`. */
37
+ const MAX_VALUE_LENGTH = 1024;
38
+ /** The ingest bound on a `data:updated` entry's `label`. */
39
+ const MAX_LABEL_LENGTH = 256;
40
+ /** Maps a form field's declared type to the analytics `field:updated` class. */
41
+ function mapFieldUpdatedType(type) {
42
+ return type;
43
+ }
44
+ /**
45
+ * Stringifies values for `data:updated` entries. `field:updated` carries the
46
+ * raw `field_value`; only the batch event caps and stringifies for ingest.
47
+ */
48
+ function formatFieldValue({ value }) {
49
+ return (typeof value === "string" ? value : JSON.stringify(value)).slice(0, MAX_VALUE_LENGTH);
50
+ }
51
+ /** One event carrying every key in one `.set()` call. */
52
+ function buildDataUpdatedEvent({ fields, patch }) {
53
+ const byKey = new Map(fields.map((field) => [field.key, field]));
54
+ return {
55
+ event_name: "data:updated",
56
+ data: Object.fromEntries(Object.entries(patch).map(([key, value]) => {
57
+ const field = byKey.get(key);
58
+ return [key, {
59
+ value: formatFieldValue({
60
+ value,
61
+ field
62
+ }),
63
+ label: (field?.label ?? key).slice(0, MAX_LABEL_LENGTH)
64
+ }];
65
+ }))
66
+ };
67
+ }
68
+ /** One `field:updated` per changed key, emitted alongside `data:updated`. */
69
+ function buildFieldUpdatedEvents({ fields, patch }) {
70
+ const byKey = new Map(fields.map((field) => [field.key, field]));
71
+ return Object.entries(patch).map(([key, value]) => {
72
+ const field = byKey.get(key);
73
+ const event = {
74
+ event_name: "field:updated",
75
+ field_key: key,
76
+ field_type: mapFieldUpdatedType(field?.type ?? "text"),
77
+ field_value: value
78
+ };
79
+ if (field?.registryFieldId !== void 0) event.registry_field_id = field.registryFieldId;
80
+ if (field?.protocolFieldId !== void 0) event.protocol_field_id = field.protocolFieldId;
81
+ return event;
82
+ });
83
+ }
84
+ /**
85
+ * Returns null when persistence cannot be configured (missing publishable key
86
+ * or fetch). The SDK keeps the no-op default in that case.
87
+ */
88
+ function resolvePersistenceConfig(config) {
89
+ const core = config.core;
90
+ const publishableKey = config.publishableKey ?? core.getPublishableKey();
91
+ if (!publishableKey || !isValidPublishableKey(publishableKey)) return null;
92
+ const projectId = core.getProjectId();
93
+ const appUserId = core.getAppUserId();
94
+ if (!projectId || !appUserId) return null;
95
+ const fetchImpl = config.fetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : void 0);
96
+ if (!fetchImpl) return null;
97
+ return {
98
+ core,
99
+ projectId,
100
+ appUserId,
101
+ publishableKey,
102
+ baseUrl: config.baseUrl ?? "https://backend-worker.heysavvy.workers.dev",
103
+ fetch: fetchImpl,
104
+ timeoutMs: config.timeoutMs ?? 1e4
105
+ };
106
+ }
107
+ //#endregion
108
+ //#region src/persistence.ts
109
+ /**
110
+ * The default: does nothing, never throws, and recovers nothing. With this in
111
+ * place a form is pure local state, exactly as before the port existed.
112
+ */
113
+ function createNoopPersistence() {
114
+ return {
115
+ savePartial: () => void 0,
116
+ saveFields: () => void 0,
117
+ saveSubmission: () => void 0,
118
+ recoverRegistryFields: () => ({})
119
+ };
120
+ }
121
+ //#endregion
122
+ //#region src/persistence-client.ts
123
+ const PUBLISHABLE_KEY_HEADER = "x-publishable-key";
124
+ const hcWithType = (...args) => hc(...args);
125
+ function withTimeout(fetchImpl, timeoutMs) {
126
+ return async (input, init) => {
127
+ const controller = new AbortController();
128
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
129
+ const path = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
130
+ try {
131
+ return await fetchImpl(input, {
132
+ ...init,
133
+ signal: controller.signal
134
+ });
135
+ } catch (error) {
136
+ if (error instanceof Error && error.name === "AbortError") throw new Error(`Request to ${path} timed out after ${timeoutMs}ms`, { cause: error });
137
+ throw new Error(`Request to ${path} failed to reach the API`, { cause: error });
138
+ } finally {
139
+ clearTimeout(timer);
140
+ }
141
+ };
142
+ }
143
+ async function rpcCall(fn) {
144
+ const res = await fn();
145
+ if (!res.ok) {
146
+ const problem = await res.json().catch(() => null);
147
+ const message = problem?.detail ?? problem?.title ?? `forms persistence failed: ${res.status}`;
148
+ throw new Error(message);
149
+ }
150
+ return res.json();
151
+ }
152
+ function createApiPersistence(config) {
153
+ const root = config.baseUrl.replace(/\/+$/, "");
154
+ const rpc = hcWithType(`${root}/forms`, {
155
+ fetch: withTimeout(config.fetch, config.timeoutMs),
156
+ headers: { [PUBLISHABLE_KEY_HEADER]: config.publishableKey }
157
+ });
158
+ return {
159
+ savePartial({ formKey, values }) {
160
+ return rpcCall(() => rpc.v1.public.sessions.$post({ json: {
161
+ project_id: config.projectId,
162
+ app_user_id: config.appUserId,
163
+ form_id: formKey,
164
+ data: values
165
+ } })).then(() => void 0);
166
+ },
167
+ saveFields: () => void 0,
168
+ saveSubmission({ formKey, values }) {
169
+ return rpcCall(() => rpc.v1.public.submissions.$post({ json: {
170
+ project_id: config.projectId,
171
+ app_user_id: config.appUserId,
172
+ form_id: formKey,
173
+ values
174
+ } })).then(() => void 0);
175
+ },
176
+ recoverRegistryFields: () => ({})
177
+ };
178
+ }
179
+ function resolveDefaultPersistence(config) {
180
+ const resolved = resolvePersistenceConfig(config);
181
+ if (!resolved) return createNoopPersistence();
182
+ return createApiPersistence(resolved);
183
+ }
184
+ //#endregion
185
+ //#region src/resolve.ts
186
+ const FIELD_TYPES = [
187
+ "text",
188
+ "email",
189
+ "number",
190
+ "boolean",
191
+ "select",
192
+ "multiselect",
193
+ "json"
194
+ ];
195
+ const VALIDATION_RULES = [
196
+ "required",
197
+ "minLength",
198
+ "maxLength",
199
+ "min",
200
+ "max",
201
+ "pattern",
202
+ "patternFlags",
203
+ "oneOf",
204
+ "custom"
205
+ ];
206
+ const NUMERIC_RULES = [
207
+ "minLength",
208
+ "maxLength",
209
+ "min",
210
+ "max"
211
+ ];
212
+ /** The ingest `z.string().max(128)` bound on a `data:updated` key. */
213
+ const MAX_FIELD_KEY_LENGTH = 128;
214
+ /** The ingest `z.string().max(128)` bound on a `form:submitted` key. */
215
+ const MAX_FORM_KEY_LENGTH = 128;
216
+ const VALID_PATTERN_FLAGS = /^[dgimsuvy]*$/;
217
+ const RESOLVED_SCHEMAS = /* @__PURE__ */ new WeakMap();
218
+ function resolveForm({ schema }) {
219
+ const memoized = RESOLVED_SCHEMAS.get(schema);
220
+ if (memoized) return memoized;
221
+ const resolved = validateAndCompile({ schema });
222
+ RESOLVED_SCHEMAS.set(schema, resolved);
223
+ return resolved;
224
+ }
225
+ function validateAndCompile({ schema }) {
226
+ const root = schema;
227
+ assertJsonRepresentable({ root });
228
+ if (!isObjectLike$1(root)) throw new SchemaError("schema: must be an object");
229
+ const formKey = root["id"];
230
+ if (typeof formKey !== "string" || formKey.trim() === "") throw new SchemaError("schema.id: must be a non-empty string");
231
+ if (formKey !== formKey.trim()) throw new SchemaError("schema.id: must not have leading or trailing whitespace");
232
+ if (formKey.length > MAX_FORM_KEY_LENGTH) throw new SchemaError(`schema.id: must be at most ${MAX_FORM_KEY_LENGTH} characters (received ${formKey.length})`);
233
+ const name = root["name"];
234
+ if (name !== void 0) {
235
+ if (typeof name !== "string") throw new SchemaError("schema.name: must be a string");
236
+ if (name.trim() === "") throw new SchemaError("schema.name: must be a non-empty string");
237
+ }
238
+ if (!Array.isArray(root["fields"])) throw new SchemaError("schema.fields: must be an array");
239
+ const fields = root["fields"];
240
+ if (fields.length === 0) throw new SchemaError("schema.fields: must declare at least one field");
241
+ const patterns = /* @__PURE__ */ new Map();
242
+ const seenKeys = /* @__PURE__ */ new Set();
243
+ fields.forEach((field, index) => {
244
+ validateField({
245
+ field,
246
+ path: `schema.fields[${index}]`,
247
+ seenKeys,
248
+ patterns
249
+ });
250
+ });
251
+ return {
252
+ formKey,
253
+ fields: [...fields],
254
+ patterns
255
+ };
256
+ }
257
+ function validateField({ field, path, seenKeys, patterns }) {
258
+ if (!isObjectLike$1(field)) throw new SchemaError(`${path}: must be an object`);
259
+ const key = field["key"];
260
+ if (typeof key !== "string" || key.trim() === "") throw new SchemaError(`${path}.key: must be a non-empty string`);
261
+ if (key.length > MAX_FIELD_KEY_LENGTH) throw new SchemaError(`${path}.key: must be at most ${MAX_FIELD_KEY_LENGTH} characters (received ${key.length})`);
262
+ if (seenKeys.has(key)) throw new SchemaError(`${path}.key: duplicate field key "${key}" in this form`);
263
+ seenKeys.add(key);
264
+ const label = field["label"];
265
+ if (typeof label !== "string" || label.trim() === "") throw new SchemaError(`${path}.label: must be a non-empty string`);
266
+ const type = field["type"];
267
+ if (typeof type !== "string" || !FIELD_TYPES.includes(type)) throw new SchemaError(`${path}.type: must be one of ${FIELD_TYPES.join(", ")}`);
268
+ const registryFieldId = field["registryFieldId"];
269
+ if (registryFieldId !== void 0 && typeof registryFieldId !== "string") throw new SchemaError(`${path}.registryFieldId: must be a string`);
270
+ const protocolFieldId = field["protocolFieldId"];
271
+ if (protocolFieldId !== void 0 && typeof protocolFieldId !== "string") throw new SchemaError(`${path}.protocolFieldId: must be a string`);
272
+ const validations = field["validations"];
273
+ if (validations === void 0) return;
274
+ validateValidations({
275
+ validations,
276
+ path: `${path}.validations`
277
+ });
278
+ if (!isObjectLike$1(validations)) return;
279
+ const pattern = validations["pattern"];
280
+ if (typeof pattern !== "string") return;
281
+ const flags = validations["patternFlags"];
282
+ patterns.set(key, compilePattern({
283
+ pattern,
284
+ flags: typeof flags === "string" ? flags : "",
285
+ path: `${path}.validations.pattern`
286
+ }));
287
+ }
288
+ function validateValidations({ validations, path }) {
289
+ if (!isObjectLike$1(validations)) throw new SchemaError(`${path}: must be an object`);
290
+ 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(", ")}`);
291
+ const required = validations["required"];
292
+ if (required !== void 0 && typeof required !== "boolean") throw new SchemaError(`${path}.required: must be a boolean`);
293
+ for (const rule of NUMERIC_RULES) {
294
+ const value = validations[rule];
295
+ if (value !== void 0 && !(typeof value === "number" && Number.isFinite(value))) throw new SchemaError(`${path}.${rule}: must be a finite number`);
296
+ }
297
+ const minLength = validations["minLength"];
298
+ const maxLength = validations["maxLength"];
299
+ if (typeof minLength === "number" && typeof maxLength === "number" && maxLength < minLength) throw new SchemaError(`${path}.maxLength: must be greater than or equal to minLength`);
300
+ const min = validations["min"];
301
+ const max = validations["max"];
302
+ if (typeof min === "number" && typeof max === "number" && max < min) throw new SchemaError(`${path}.max: must be greater than or equal to min`);
303
+ const oneOf = validations["oneOf"];
304
+ if (oneOf !== void 0 && !(Array.isArray(oneOf) && oneOf.length > 0)) throw new SchemaError(`${path}.oneOf: must be a non-empty array`);
305
+ const pattern = validations["pattern"];
306
+ if (pattern !== void 0 && typeof pattern !== "string") throw new SchemaError(`${path}.pattern: must be a string`);
307
+ const patternFlags = validations["patternFlags"];
308
+ if (patternFlags !== void 0 && !(typeof patternFlags === "string" && VALID_PATTERN_FLAGS.test(patternFlags))) throw new SchemaError(`${path}.patternFlags: must contain only the characters dgimsuvy`);
309
+ const custom = validations["custom"];
310
+ if (custom !== void 0 && typeof custom !== "function") throw new SchemaError(`${path}.custom: must be a function (received ${typeof custom})`);
311
+ }
312
+ function compilePattern({ pattern, flags, path }) {
313
+ try {
314
+ return new RegExp(pattern, flags.replace(/[gy]/g, ""));
315
+ } catch (error) {
316
+ throw new SchemaError(`${path}: invalid pattern — ${errorMessage(error)}`, { cause: error });
317
+ }
318
+ }
319
+ /**
320
+ * Rejects every value in the config that would not survive
321
+ * `JSON.parse(JSON.stringify(x))` — a function included, except at the one
322
+ * permitted location, `validations.custom` on a field.
323
+ */
324
+ function assertJsonRepresentable({ root }) {
325
+ const stripped = withoutFieldValidators(root);
326
+ const serialized = stringifyOrUndefined(stripped);
327
+ if (serialized === void 0) throw new SchemaError(`${findNonJsonPath({
328
+ value: stripped,
329
+ path: "schema",
330
+ seen: /* @__PURE__ */ new Set()
331
+ }) ?? "schema"}: value cannot be serialized to JSON`);
332
+ const mismatch = firstMismatch({
333
+ actual: stripped,
334
+ expected: JSON.parse(serialized),
335
+ path: "schema"
336
+ });
337
+ 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`);
338
+ }
339
+ function withoutFieldValidators(root) {
340
+ if (!isObjectLike$1(root)) return root;
341
+ if (!Array.isArray(root["fields"])) return root;
342
+ const fields = root["fields"];
343
+ return {
344
+ ...root,
345
+ fields: fields.map(stripField)
346
+ };
347
+ }
348
+ function stripField(field) {
349
+ if (!isObjectLike$1(field)) return field;
350
+ const validations = field["validations"];
351
+ if (!isObjectLike$1(validations) || !("custom" in validations)) return field;
352
+ const { custom: _custom, ...rest } = validations;
353
+ return {
354
+ ...field,
355
+ validations: rest
356
+ };
357
+ }
358
+ function stringifyOrUndefined(value) {
359
+ try {
360
+ return JSON.stringify(value);
361
+ } catch {
362
+ return;
363
+ }
364
+ }
365
+ /** The path of the first value `JSON.stringify` cannot handle at all. */
366
+ function findNonJsonPath({ value, path, seen }) {
367
+ if (typeof value === "bigint" || typeof value === "symbol") return path;
368
+ if (value === null || typeof value !== "object") return void 0;
369
+ if (seen.has(value)) return path;
370
+ seen.add(value);
371
+ for (const [childPath, child] of childEntries({
372
+ value,
373
+ path
374
+ })) {
375
+ const found = findNonJsonPath({
376
+ value: child,
377
+ path: childPath,
378
+ seen
379
+ });
380
+ if (found) return found;
381
+ }
382
+ seen.delete(value);
383
+ }
384
+ function childEntries({ value, path }) {
385
+ if (Array.isArray(value)) return value.map((item, index) => [`${path}[${index}]`, item]);
386
+ return Object.entries(value).map(([key, item]) => [`${path}.${key}`, item]);
387
+ }
388
+ /** The path of the first value that changed across the round trip. */
389
+ function firstMismatch({ actual, expected, path }) {
390
+ if (Array.isArray(actual) || Array.isArray(expected)) {
391
+ if (!Array.isArray(actual) || !Array.isArray(expected)) return path;
392
+ const actualItems = actual;
393
+ const expectedItems = expected;
394
+ if (actualItems.length !== expectedItems.length) return path;
395
+ for (const [index, item] of actualItems.entries()) {
396
+ const found = firstMismatch({
397
+ actual: item,
398
+ expected: expectedItems[index],
399
+ path: `${path}[${index}]`
400
+ });
401
+ if (found) return found;
402
+ }
403
+ return;
404
+ }
405
+ if (isJsonObject(actual) && isJsonObject(expected)) {
406
+ const actualKeys = Object.keys(actual);
407
+ const expectedKeys = Object.keys(expected);
408
+ if (actualKeys.length !== expectedKeys.length) {
409
+ const dropped = actualKeys.find((key) => !expectedKeys.includes(key));
410
+ return dropped === void 0 ? path : `${path}.${dropped}`;
411
+ }
412
+ for (const key of actualKeys) {
413
+ const found = firstMismatch({
414
+ actual: actual[key],
415
+ expected: expected[key],
416
+ path: `${path}.${key}`
417
+ });
418
+ if (found) return found;
419
+ }
420
+ return;
421
+ }
422
+ return actual === expected ? void 0 : path;
423
+ }
424
+ function isObjectLike$1(value) {
425
+ return typeof value === "object" && value !== null && !Array.isArray(value);
426
+ }
427
+ /** Narrower than `isObjectLike`: a `Date`, `RegExp`, or class instance is not one. */
428
+ function isJsonObject(value) {
429
+ if (!isObjectLike$1(value)) return false;
430
+ const prototype = Object.getPrototypeOf(value);
431
+ return prototype === Object.prototype || prototype === null;
432
+ }
433
+ function errorMessage(error) {
434
+ return error instanceof Error ? error.message : String(error);
435
+ }
436
+ //#endregion
437
+ //#region src/storage.ts
438
+ /** Every form on the origin shares this one entry, indexed by form key. */
439
+ const FORM_DATA_KEY = "EMBEDDABLES-FORM-DATA";
440
+ const LIVE_DOCUMENTS = /* @__PURE__ */ new WeakMap();
441
+ function readDocumentFromStorage({ storage }) {
442
+ try {
443
+ const raw = storage.getItem(FORM_DATA_KEY);
444
+ if (raw === null) return {};
445
+ const parsed = JSON.parse(raw);
446
+ if (!isObjectLike(parsed)) return {};
447
+ return parsed;
448
+ } catch {
449
+ return {};
450
+ }
451
+ }
452
+ /**
453
+ * Load once per storage object; later calls reuse the in-memory document.
454
+ *
455
+ * There is no invalidation: a write from another tab or a user clearing site
456
+ * data is never picked up, and the next write here overwrites it. Recovering
457
+ * from an external mutation means constructing a new storage object.
458
+ */
459
+ function loadDocument({ storage }) {
460
+ const cached = LIVE_DOCUMENTS.get(storage);
461
+ if (cached) return cached;
462
+ const document = readDocumentFromStorage({ storage });
463
+ LIVE_DOCUMENTS.set(storage, document);
464
+ return document;
465
+ }
466
+ function resolveStorage({ storage }) {
467
+ if (storage) return storage;
468
+ try {
469
+ const candidate = globalThis.localStorage;
470
+ candidate.getItem(FORM_DATA_KEY);
471
+ return candidate;
472
+ } catch {
473
+ return createMemoryStorage();
474
+ }
475
+ }
476
+ /**
477
+ * A fresh in-memory shim seeded with the document as last read, for an instance
478
+ * whose real storage started throwing mid-session.
479
+ */
480
+ function degradeToMemory({ storage }) {
481
+ const shim = createMemoryStorage();
482
+ const snapshot = { ...LIVE_DOCUMENTS.get(storage) ?? readDocumentFromStorage({ storage }) };
483
+ shim.setItem(FORM_DATA_KEY, JSON.stringify(snapshot));
484
+ LIVE_DOCUMENTS.set(shim, snapshot);
485
+ return shim;
486
+ }
487
+ function readFields({ storage, formKey }) {
488
+ const bag = loadDocument({ storage })[formKey];
489
+ if (!isObjectLike(bag)) return {};
490
+ return bag;
491
+ }
492
+ function writeFields({ storage, formKey, fields }) {
493
+ const next = {
494
+ ...loadDocument({ storage }),
495
+ [formKey]: { ...fields }
496
+ };
497
+ storage.setItem(FORM_DATA_KEY, JSON.stringify(next));
498
+ LIVE_DOCUMENTS.set(storage, next);
499
+ }
500
+ function removeFields({ storage, formKey }) {
501
+ const { [formKey]: _dropped, ...rest } = loadDocument({ storage });
502
+ if (Object.keys(rest).length === 0) {
503
+ storage.removeItem(FORM_DATA_KEY);
504
+ LIVE_DOCUMENTS.set(storage, rest);
505
+ return;
506
+ }
507
+ storage.setItem(FORM_DATA_KEY, JSON.stringify(rest));
508
+ LIVE_DOCUMENTS.set(storage, rest);
509
+ }
510
+ /**
511
+ * Whether a value survives `JSON.stringify`. `undefined`, a function, and a
512
+ * `Symbol` make it return `undefined`; a circular reference and a `BigInt` make
513
+ * it throw. All five must be rejected before a write, because a value that
514
+ * cannot stringify aborts a write carrying every form's data.
515
+ */
516
+ function isSerializable({ value }) {
517
+ try {
518
+ return typeof JSON.stringify(value) === "string";
519
+ } catch {
520
+ return false;
521
+ }
522
+ }
523
+ function createMemoryStorage() {
524
+ const entries = /* @__PURE__ */ new Map();
525
+ return {
526
+ getItem: (key) => entries.get(key) ?? null,
527
+ setItem: (key, value) => {
528
+ entries.set(key, value);
529
+ },
530
+ removeItem: (key) => {
531
+ entries.delete(key);
532
+ }
533
+ };
534
+ }
535
+ function isObjectLike(value) {
536
+ return typeof value === "object" && value !== null && !Array.isArray(value);
537
+ }
538
+ //#endregion
539
+ //#region src/validation.ts
540
+ /** Runtime counterpart to `FieldType`. Exhaustive by construction. */
541
+ const FIELD_TYPE_PREDICATES = {
542
+ text: (value) => typeof value === "string",
543
+ email: (value) => typeof value === "string",
544
+ number: (value) => typeof value === "number",
545
+ boolean: (value) => typeof value === "boolean",
546
+ select: (value) => typeof value === "string",
547
+ multiselect: (value) => Array.isArray(value),
548
+ json: () => true
549
+ };
550
+ 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])?)*$/;
551
+ /**
552
+ * Every message a single field's value earns. Empty means valid. Not generic:
553
+ * the per-field types live at the instance boundary, and the cast down to
554
+ * `JsonValue` happens once, in `initForm`.
555
+ */
556
+ function validateValue({ field, value, values, pattern, validator }) {
557
+ const rules = field.validations;
558
+ const messages = [];
559
+ const isAbsent = value === void 0 || value === null;
560
+ const isBlank = isAbsent || value === "" || Array.isArray(value) && value.length === 0;
561
+ if (rules?.required === true && isBlank) messages.push(`${field.label} is required`);
562
+ if (isAbsent) return messages;
563
+ if (!FIELD_TYPE_PREDICATES[field.type](value)) messages.push(`${field.label} expects ${/^[aeiou]/.test(field.type) ? "an" : "a"} ${field.type} value`);
564
+ if (typeof value === "string") {
565
+ if (field.type === "email" && value !== "" && !EMAIL_PATTERN.test(value)) messages.push(`${field.label} must be a valid email address`);
566
+ if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must be at least ${rules.minLength} characters`);
567
+ if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must be at most ${rules.maxLength} characters`);
568
+ if (pattern && !pattern.test(value)) messages.push(`${field.label} is not in the expected format`);
569
+ }
570
+ if (Array.isArray(value)) {
571
+ if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must have at least ${rules.minLength} items`);
572
+ if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must have at most ${rules.maxLength} items`);
573
+ }
574
+ if (typeof value === "number") {
575
+ if (rules?.min !== void 0 && value < rules.min) messages.push(`${field.label} must be at least ${rules.min}`);
576
+ if (rules?.max !== void 0 && value > rules.max) messages.push(`${field.label} must be at most ${rules.max}`);
577
+ }
578
+ if (rules?.oneOf) {
579
+ const encoded = canonicalize(value);
580
+ if (!rules.oneOf.some((option) => canonicalize(option) === encoded)) messages.push(`${field.label} must be one of the allowed options`);
581
+ }
582
+ if (!validator || messages.length > 0) return messages;
583
+ return normalizeValidatorResult({
584
+ result: validator({
585
+ value,
586
+ values
587
+ }),
588
+ field
589
+ });
590
+ }
591
+ function normalizeValidatorResult({ result, field }) {
592
+ if (isThenable(result)) throw new ValidatorError(`Validator for "${field.key}" returned a promise; custom validators must be synchronous`);
593
+ if (result === null || result === void 0) return [];
594
+ if (typeof result === "string") return [result];
595
+ if (Array.isArray(result)) return result.filter((entry) => typeof entry === "string");
596
+ throw new ValidatorError(`Validator for "${field.key}" returned ${typeof result}; expected a string, an array of strings, or null`);
597
+ }
598
+ function isThenable(value) {
599
+ return typeof value?.then === "function";
600
+ }
601
+ /**
602
+ * JSON encoding with object keys sorted at every depth, so two values compare
603
+ * by content rather than by insertion order.
604
+ */
605
+ function canonicalize(value) {
606
+ const walk = (input) => {
607
+ if (Array.isArray(input)) return input.map(walk);
608
+ 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)]));
609
+ return input;
610
+ };
611
+ return JSON.stringify(walk(value));
612
+ }
613
+ //#endregion
614
+ //#region src/form.ts
615
+ const REQUIRED_CORE_METHODS = [
616
+ "getAppUserId",
617
+ "getProjectId",
618
+ "getPublishableKey"
619
+ ];
620
+ function hasRequiredCoreMethods(value) {
621
+ if (typeof value !== "object" || value === null) return false;
622
+ return REQUIRED_CORE_METHODS.every((method) => typeof value[method] === "function");
623
+ }
624
+ function mergeCustomValidations({ schema, customValidations }) {
625
+ if (!customValidations) return schema;
626
+ const declared = new Set(schema.fields.map((field) => field.key));
627
+ for (const key of Object.keys(customValidations)) {
628
+ if (!declared.has(key)) throw new SchemaError(`customValidations: unknown field key "${key}"`);
629
+ if (typeof customValidations[key] !== "function") throw new SchemaError(`customValidations.${key}: must be a function`);
630
+ }
631
+ const fields = schema.fields.map((field) => {
632
+ const custom = customValidations[field.key];
633
+ if (!custom) return field;
634
+ return {
635
+ ...field,
636
+ validations: {
637
+ ...field.validations,
638
+ custom
639
+ }
640
+ };
641
+ });
642
+ return {
643
+ ...schema,
644
+ fields
645
+ };
646
+ }
647
+ /**
648
+ * Validates the core instance once, then returns a per-form `initForm`. Public
649
+ * API — the Miro signature. Consumers pass only `core` and, optionally, an
650
+ * analytics client.
651
+ */
652
+ function initForms(options) {
653
+ return createFormsClient(options);
654
+ }
655
+ function createFormsClient({ core, analyticsInstance, baseUrl, storage, persistence }) {
656
+ if (!hasRequiredCoreMethods(core)) throw new FormsError("initForms requires an initialized Embeddables core instance.");
657
+ const resolvedPersistence = persistence ?? resolveDefaultPersistence({
658
+ core,
659
+ baseUrl
660
+ });
661
+ return { initForm: (options) => createFormInstance({
662
+ analyticsInstance,
663
+ storage,
664
+ persistence: resolvedPersistence,
665
+ schema: options.schema,
666
+ customValidations: options.customValidations
667
+ }) };
668
+ }
669
+ function createFormInstance({ analyticsInstance, storage, persistence, schema, customValidations }) {
670
+ const resolved = resolveForm({ schema: mergeCustomValidations({
671
+ schema,
672
+ customValidations
673
+ }) });
674
+ const declared = new Map(resolved.fields.map((field) => [field.key, field]));
675
+ const resolvedStorage = resolveStorage({ storage });
676
+ const state = {
677
+ storage: resolvedStorage,
678
+ bag: { ...readFields({
679
+ storage: resolvedStorage,
680
+ formKey: resolved.formKey
681
+ }) },
682
+ errors: /* @__PURE__ */ new Map()
683
+ };
684
+ const firePersistence = (run) => {
685
+ try {
686
+ const result = run();
687
+ if (result instanceof Promise) result.then(void 0, () => void 0);
688
+ } catch {}
689
+ };
690
+ const mergeRecovered = (recovered) => {
691
+ for (const [key, value] of Object.entries(recovered)) if (value !== void 0 && state.bag[key] === void 0) state.bag[key] = value;
692
+ };
693
+ const recoverable = resolved.fields.filter((field) => (field.registryFieldId !== void 0 || field.protocolFieldId !== void 0) && state.bag[field.key] === void 0).map((field) => ({
694
+ key: field.key,
695
+ registryFieldId: field.registryFieldId,
696
+ protocolFieldId: field.protocolFieldId
697
+ }));
698
+ if (recoverable.length > 0) try {
699
+ const result = persistence.recoverRegistryFields({
700
+ formKey: resolved.formKey,
701
+ fields: recoverable
702
+ });
703
+ if (result instanceof Promise) result.then((recovered) => mergeRecovered(recovered ?? {}), () => void 0);
704
+ else mergeRecovered(result);
705
+ } catch {}
706
+ const freeze = (entries) => Object.freeze(Object.fromEntries(entries));
707
+ const noErrors = () => freeze(/* @__PURE__ */ new Map());
708
+ /** The stored bag narrowed to the keys the config declares. */
709
+ const narrow = (bag) => {
710
+ const narrowed = {};
711
+ for (const field of resolved.fields) {
712
+ const value = bag[field.key];
713
+ if (value !== void 0) narrowed[field.key] = value;
714
+ }
715
+ return narrowed;
716
+ };
717
+ const readBag = () => state.bag;
718
+ const validatorFor = (field) => field.validations?.custom;
719
+ const validateDeclaredFields = ({ snapshot, keys }) => {
720
+ const errors = /* @__PURE__ */ new Map();
721
+ for (const key of keys) {
722
+ const field = declared.get(key);
723
+ if (!field) continue;
724
+ const messages = validateValue({
725
+ field,
726
+ value: snapshot[field.key],
727
+ values: snapshot,
728
+ pattern: resolved.patterns.get(key),
729
+ validator: validatorFor(field)
730
+ });
731
+ if (messages.length > 0) errors.set(key, messages);
732
+ }
733
+ return errors;
734
+ };
735
+ const replaceErrors = (errors) => {
736
+ state.errors.clear();
737
+ for (const [key, messages] of errors) state.errors.set(key, messages);
738
+ };
739
+ const applyPatchValidationErrors = ({ errors, patchKeys }) => {
740
+ for (const key of patchKeys) {
741
+ const messages = errors.get(key);
742
+ if (messages) state.errors.set(key, messages);
743
+ else state.errors.delete(key);
744
+ }
745
+ };
746
+ const set = (patch) => {
747
+ const changes = patch;
748
+ const entries = Object.entries(changes);
749
+ if (entries.length === 0) return Promise.resolve({
750
+ ok: true,
751
+ errors: noErrors()
752
+ });
753
+ const errors = /* @__PURE__ */ new Map();
754
+ for (const [key, value] of entries) {
755
+ const field = declared.get(key);
756
+ if (!field) {
757
+ errors.set(key, [`Unknown field: ${key}`]);
758
+ continue;
759
+ }
760
+ if (!isSerializable({ value })) errors.set(key, [`${field.label} value is not JSON-serializable`]);
761
+ }
762
+ const candidate = {
763
+ ...readBag(),
764
+ ...changes
765
+ };
766
+ const snapshot = narrow(candidate);
767
+ for (const [key, value] of entries) {
768
+ const field = declared.get(key);
769
+ if (!field || errors.has(key)) continue;
770
+ const messages = validateValue({
771
+ field,
772
+ value,
773
+ values: snapshot,
774
+ pattern: resolved.patterns.get(key),
775
+ validator: validatorFor(field)
776
+ });
777
+ if (messages.length > 0) errors.set(key, messages);
778
+ }
779
+ if (errors.size > 0) {
780
+ for (const [key, messages] of errors) state.errors.set(key, messages);
781
+ return Promise.resolve({
782
+ ok: false,
783
+ errors: freeze(errors)
784
+ });
785
+ }
786
+ for (const [key] of entries) state.errors.delete(key);
787
+ try {
788
+ writeFields({
789
+ storage: state.storage,
790
+ formKey: resolved.formKey,
791
+ fields: candidate
792
+ });
793
+ state.bag = candidate;
794
+ } catch {
795
+ return Promise.resolve(degradeAndReport({ entries }));
796
+ }
797
+ const persistedFields = entries.map(([key, value]) => {
798
+ const field = declared.get(key);
799
+ return {
800
+ key,
801
+ value,
802
+ registryFieldId: field?.registryFieldId,
803
+ protocolFieldId: field?.protocolFieldId
804
+ };
805
+ });
806
+ firePersistence(() => persistence.savePartial({
807
+ formKey: resolved.formKey,
808
+ values: snapshot
809
+ }));
810
+ firePersistence(() => persistence.saveFields({
811
+ formKey: resolved.formKey,
812
+ fields: persistedFields
813
+ }));
814
+ if (!analyticsInstance) return Promise.resolve({
815
+ ok: true,
816
+ errors: noErrors()
817
+ });
818
+ return analyticsInstance.trackEvent([buildDataUpdatedEvent({
819
+ fields: resolved.fields,
820
+ patch: changes
821
+ }), ...buildFieldUpdatedEvents({
822
+ fields: resolved.fields,
823
+ patch: changes
824
+ })]).then(() => ({
825
+ ok: true,
826
+ errors: noErrors()
827
+ })).catch((error) => ({
828
+ ok: true,
829
+ errors: noErrors(),
830
+ trackError: error
831
+ }));
832
+ };
833
+ const degradeAndReport = ({ entries }) => {
834
+ state.storage = degradeToMemory({ storage: state.storage });
835
+ const errors = /* @__PURE__ */ new Map();
836
+ for (const [key] of entries) {
837
+ const message = `${declared.get(key)?.label ?? key} could not be persisted; this form is now in-memory only`;
838
+ errors.set(key, [message]);
839
+ state.errors.set(key, [message]);
840
+ }
841
+ return {
842
+ ok: false,
843
+ errors: freeze(errors)
844
+ };
845
+ };
846
+ const get = (key) => {
847
+ const fieldKey = key;
848
+ if (!declared.has(fieldKey)) return void 0;
849
+ return state.bag[fieldKey];
850
+ };
851
+ const getAll = () => narrow(state.bag);
852
+ const submit = () => {
853
+ const snapshot = narrow(readBag());
854
+ const values = snapshot;
855
+ const errors = validateDeclaredFields({
856
+ snapshot,
857
+ keys: resolved.fields.map((field) => field.key)
858
+ });
859
+ if (errors.size > 0) {
860
+ replaceErrors(errors);
861
+ return Promise.resolve({
862
+ ok: false,
863
+ errors: freeze(errors),
864
+ values
865
+ });
866
+ }
867
+ state.errors.clear();
868
+ firePersistence(() => persistence.saveSubmission({
869
+ formKey: resolved.formKey,
870
+ values: snapshot
871
+ }));
872
+ if (!analyticsInstance) return Promise.resolve({
873
+ ok: true,
874
+ errors: noErrors(),
875
+ values
876
+ });
877
+ return analyticsInstance.trackEvent([{
878
+ event_name: "form:submitted",
879
+ form_key: resolved.formKey
880
+ }]).then(() => ({
881
+ ok: true,
882
+ errors: noErrors(),
883
+ values
884
+ })).catch((error) => ({
885
+ ok: true,
886
+ errors: noErrors(),
887
+ values,
888
+ trackError: error
889
+ }));
890
+ };
891
+ const validate = (patch) => {
892
+ if (patch === void 0) {
893
+ const snapshot = narrow(readBag());
894
+ const values = snapshot;
895
+ const errors = validateDeclaredFields({
896
+ snapshot,
897
+ keys: resolved.fields.map((field) => field.key)
898
+ });
899
+ if (errors.size > 0) {
900
+ replaceErrors(errors);
901
+ return Promise.resolve({
902
+ ok: false,
903
+ errors: freeze(errors),
904
+ values
905
+ });
906
+ }
907
+ state.errors.clear();
908
+ return Promise.resolve({
909
+ ok: true,
910
+ errors: noErrors(),
911
+ values
912
+ });
913
+ }
914
+ const changes = patch;
915
+ const entries = Object.entries(changes);
916
+ const values = narrow({
917
+ ...readBag(),
918
+ ...changes
919
+ });
920
+ if (entries.length === 0) return Promise.resolve({
921
+ ok: true,
922
+ errors: noErrors(),
923
+ values
924
+ });
925
+ const errors = /* @__PURE__ */ new Map();
926
+ const patchKeys = entries.map(([key]) => key);
927
+ for (const [key, value] of entries) {
928
+ const field = declared.get(key);
929
+ if (!field) {
930
+ errors.set(key, [`Unknown field: ${key}`]);
931
+ continue;
932
+ }
933
+ if (!isSerializable({ value })) errors.set(key, [`${field.label} value is not JSON-serializable`]);
934
+ }
935
+ const snapshot = narrow({
936
+ ...readBag(),
937
+ ...changes
938
+ });
939
+ for (const [key] of entries) {
940
+ if (errors.has(key)) continue;
941
+ const field = declared.get(key);
942
+ if (!field) continue;
943
+ const messages = validateValue({
944
+ field,
945
+ value: snapshot[key],
946
+ values: snapshot,
947
+ pattern: resolved.patterns.get(key),
948
+ validator: validatorFor(field)
949
+ });
950
+ if (messages.length > 0) errors.set(key, messages);
951
+ }
952
+ applyPatchValidationErrors({
953
+ errors,
954
+ patchKeys
955
+ });
956
+ if (errors.size > 0) return Promise.resolve({
957
+ ok: false,
958
+ errors: freeze(errors),
959
+ values
960
+ });
961
+ return Promise.resolve({
962
+ ok: true,
963
+ errors: noErrors(),
964
+ values
965
+ });
966
+ };
967
+ const clear = () => {
968
+ removeFields({
969
+ storage: state.storage,
970
+ formKey: resolved.formKey
971
+ });
972
+ state.bag = {};
973
+ state.errors.clear();
974
+ };
975
+ return {
976
+ key: schema.id,
977
+ set,
978
+ get,
979
+ getAll,
980
+ submit,
981
+ validate,
982
+ errors: () => freeze(state.errors),
983
+ clear
984
+ };
985
+ }
986
+ //#endregion
987
+ export { FORM_DATA_KEY, FormsError, SchemaError, ValidatorError, initForms };
988
+
989
+ //# sourceMappingURL=index.js.map