@pilotiq/pilotiq 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import type { ElementMeta } from '../schema/Element.js'
2
+ import type { FormCollabBinding } from './FormCollabBindingRegistry.js'
2
3
 
3
4
  /** Walk a form's child tree and collect every Field's `defaultValue` into
4
5
  * a flat values map keyed by field name. Used by `FormStateProvider` to
@@ -216,3 +217,248 @@ export function readNestedValue(
216
217
  }
217
218
  return cursor
218
219
  }
220
+
221
+ /**
222
+ * Phase F.5 — parsed row-field coordinates.
223
+ *
224
+ * `fieldName` is the leaf field's name as declared in the inner schema
225
+ * (e.g. the `TextField.make('label')` inside a `Repeater.schema([...])`).
226
+ * Builder rows wrap their inner schema under a literal `data` segment;
227
+ * `parseRowFieldPath` strips that segment so consumers don't need to
228
+ * know which row-array dialect they're addressing.
229
+ */
230
+ export interface ParsedRowFieldPath {
231
+ arrayName: string
232
+ index: number
233
+ fieldName: string
234
+ }
235
+
236
+ /**
237
+ * Phase F.5 — parse a dotted form-field name into row-array coordinates
238
+ * when it matches a Repeater or Builder row-leaf shape; return `null`
239
+ * otherwise. Used by `FormStateProvider` to route row-leaf writes
240
+ * through `FormCollabBinding.setRow` instead of skipping them.
241
+ *
242
+ * `tags.0.label` → { arrayName: 'tags', index: 0, fieldName: 'label' } (Repeater)
243
+ * `blocks.0.data.body` → { arrayName: 'blocks', index: 0, fieldName: 'body' } (Builder)
244
+ * `tags.0.__id` → null (row identity — handled by addRow / reorderRows)
245
+ * `blocks.0.type` → null (Builder block discriminator — not a user field)
246
+ * `foo` → null (top-level field — top-level binding.set path)
247
+ * `tags.notnum.label` → null (malformed)
248
+ *
249
+ * Nested Repeaters / Builders (e.g. `articles.0.comments.1.body`) are
250
+ * out of scope v1 — they return `null` here too. F.5b's binding impl
251
+ * therefore never sees them and they continue to ride the local-state
252
+ * path until a future phase opens the door.
253
+ */
254
+ export function parseRowFieldPath(path: string): ParsedRowFieldPath | null {
255
+ const segments = path.split('.')
256
+ if (segments.length === 3) {
257
+ const [arrayName, idxRaw, fieldName] = segments as [string, string, string]
258
+ if (!/^\d+$/.test(idxRaw)) return null
259
+ if (!arrayName || !fieldName) return null
260
+ if (fieldName === '__id' || fieldName === 'type') return null
261
+ return { arrayName, index: Number(idxRaw), fieldName }
262
+ }
263
+ if (segments.length === 4 && segments[2] === 'data') {
264
+ const [arrayName, idxRaw, , fieldName] = segments as [string, string, string, string]
265
+ if (!/^\d+$/.test(idxRaw)) return null
266
+ if (!arrayName || !fieldName) return null
267
+ return { arrayName, index: Number(idxRaw), fieldName }
268
+ }
269
+ return null
270
+ }
271
+
272
+ /**
273
+ * Phase F.5 — resolve the stable `__id` of the row at `arrayName[index]`
274
+ * from a flat dotted-path values map. The renderer mints `__id` on every
275
+ * row insert (UUID for new / DB PK for relationship-backed) so callers
276
+ * here are looking up an id that already exists; returns `null` only
277
+ * when the row hasn't been registered yet (e.g. the binding observed a
278
+ * remote insert before the local renderer rebuilt its row map).
279
+ *
280
+ * The lookup always reads from the flat shape (`${arrayName}.${index}.__id`)
281
+ * since both `FormStateProvider`'s `valuesRef` and server-resolve responses
282
+ * use flat dotted-path keys.
283
+ */
284
+ export function rowIdAtIndex(
285
+ values: Record<string, unknown>,
286
+ arrayName: string,
287
+ index: number,
288
+ ): string | null {
289
+ const flat = values[`${arrayName}.${index}.__id`]
290
+ if (typeof flat === 'string' && flat.length > 0) return flat
291
+ return null
292
+ }
293
+
294
+ /**
295
+ * Phase F2 — returns `true` iff the named field has explicitly opted out
296
+ * of realtime collab via `Field.collab(false)`. Sparse meta — absent =
297
+ * inherit the panel default (collab on). Walks the form meta tree the
298
+ * same way `findFieldMeta` does. Cheap (one map lookup per write).
299
+ *
300
+ * Exposed so `routeBindingWrite` can compose against it from this
301
+ * module instead of FormStateContext's private helper.
302
+ */
303
+ export function fieldOptsOutOfCollab(formMeta: ElementMeta, name: string): boolean {
304
+ const meta = findFieldMeta(formMeta, name) as { collab?: boolean } | undefined
305
+ return meta?.collab === false
306
+ }
307
+
308
+ /**
309
+ * Phase F.5 — route a single (name, value) write through the collab
310
+ * binding by name shape:
311
+ *
312
+ * - top-level name → `binding.set(name, value)` (F2 path)
313
+ * - row leaf → `binding.setRow(arrayName, rowId, fieldName, value)`
314
+ * when the binding implements F.5; falls through
315
+ * to no-op otherwise (v1 skip-on-dot behaviour).
316
+ *
317
+ * Skips when no binding is registered or the field opted out via
318
+ * `.collab(false)`. `valuesForLookup` supplies the rowId map — pass the
319
+ * latest snapshot the caller has access to (`valuesRef.current` for
320
+ * local writes; merged `valuesRef + serverValues` for server-resolve).
321
+ *
322
+ * Shared between `setValue` (local edits) and the server-resolve overlay
323
+ * inside `performLivePost` so both routing decisions stay in lockstep.
324
+ */
325
+ export function routeBindingWrite(
326
+ binding: FormCollabBinding | null,
327
+ formMeta: ElementMeta | undefined,
328
+ valuesForLookup: Record<string, unknown>,
329
+ name: string,
330
+ value: unknown,
331
+ ): void {
332
+ if (!binding) return
333
+ if (formMeta && fieldOptsOutOfCollab(formMeta, name)) return
334
+ if (!name.includes('.')) {
335
+ binding.set(name, value)
336
+ return
337
+ }
338
+ if (!binding.setRow) return // pre-F.5 binding — row leaves stay local
339
+ const parsed = parseRowFieldPath(name)
340
+ if (!parsed) return // nested-Repeater / malformed — out of scope v1
341
+ const rowId = rowIdAtIndex(valuesForLookup, parsed.arrayName, parsed.index)
342
+ if (!rowId) return // row not yet stamped — local-only until id lands
343
+ binding.setRow(parsed.arrayName, rowId, parsed.fieldName, value)
344
+ }
345
+
346
+ /**
347
+ * Phase F.5 — walk a form's meta tree for top-level Repeater / Builder
348
+ * field names. Used by `FormStateProvider` to build the per-array
349
+ * `RowBindingApi` map from a single meta walk at binding mount.
350
+ *
351
+ * Stops at the array boundary itself — we want the array's own field
352
+ * name, not the inner-row fields inside it (which address through
353
+ * `setRow`'s dotted-path routing, not through their own row bindings).
354
+ * Skips fields opted out via `.collab(false)` so the array is left on
355
+ * the v1 local-state path.
356
+ */
357
+ export function collectRowArrayFieldNames(formMeta: ElementMeta): string[] {
358
+ const out: string[] = []
359
+ walk(formMeta)
360
+ return out
361
+
362
+ function walk(node: ElementMeta): void {
363
+ if (node.type === 'field') {
364
+ const fieldType = node['fieldType']
365
+ if (fieldType === 'repeater' || fieldType === 'builder') {
366
+ if ((node as { collab?: boolean }).collab !== false) {
367
+ const name = String(node['name'] ?? '')
368
+ if (name) out.push(name)
369
+ }
370
+ // Don't descend — inner row fields address through dotted-path
371
+ // routing, not their own row bindings.
372
+ return
373
+ }
374
+ }
375
+ const children = node.children
376
+ if (Array.isArray(children)) {
377
+ for (const child of children) walk(child as ElementMeta)
378
+ }
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Phase F.5c — text-shaped fieldTypes whose row-leaf values should be
384
+ * routed through `Y.Text` instead of `Y.Map` LWW. Mirrors the same
385
+ * allowlist `@pilotiq-pro/collab`'s top-level binding uses; consumers
386
+ * registering character-level CRDT for additional plain-text-shaped
387
+ * fields update both copies in lockstep until a cross-repo shared
388
+ * constants module exists.
389
+ */
390
+ const ROW_TEXT_FIELD_TYPES: ReadonlySet<string> = new Set([
391
+ 'text', 'textarea', 'email', 'slug', 'markdown',
392
+ ])
393
+
394
+ /**
395
+ * Phase F.5c — per-Repeater/Builder set of inner-field names that
396
+ * carry text-shaped leaves eligible for character-level CRDT. Drives
397
+ * `useFieldState(dottedName).textBinding` resolution: only fields in
398
+ * the per-array set go through `binding.getRowTextBinding`; everything
399
+ * else stays on row-level Y.Map LWW.
400
+ *
401
+ * Repeater rows expose their schema directly under `meta.children`;
402
+ * Builder rows nest schemas under `meta.blocks[i].template`. The
403
+ * walker descends through every block's template so a `markdown` leaf
404
+ * inside any block-type lands in the array's allowlist. Nested
405
+ * Repeaters / Builders inside row schemas are out of scope v1 (their
406
+ * dotted paths are 5+ segments and `parseRowFieldPath` rejects them).
407
+ */
408
+ export function collectRowTextLeavesByArray(formMeta: ElementMeta): Map<string, Set<string>> {
409
+ const out = new Map<string, Set<string>>()
410
+ walkTop(formMeta)
411
+ return out
412
+
413
+ function walkTop(node: ElementMeta): void {
414
+ if (node.type === 'field') {
415
+ const fieldType = String(node['fieldType'] ?? '')
416
+ if (fieldType === 'repeater' || fieldType === 'builder') {
417
+ if ((node as { collab?: boolean }).collab === false) return
418
+ const name = String(node['name'] ?? '')
419
+ if (!name) return
420
+ const set = new Set<string>()
421
+ // Repeater's `toMeta()` emits the row schema under `template` (not
422
+ // `children` — that's per-resolved-row). Builder nests row schemas
423
+ // under `blocks[i].template`. Reading `children` here pre-fix gave
424
+ // every Repeater an empty text-leaf set → row text never CRDT'd.
425
+ if (fieldType === 'repeater') walkRow((node as { template?: unknown }).template, set)
426
+ else walkBlocks((node as { blocks?: unknown }).blocks, set)
427
+ if (set.size > 0) out.set(name, set)
428
+ return
429
+ }
430
+ }
431
+ const children = node.children
432
+ if (Array.isArray(children)) {
433
+ for (const child of children) walkTop(child as ElementMeta)
434
+ }
435
+ }
436
+
437
+ function walkRow(children: unknown, set: Set<string>): void {
438
+ if (!Array.isArray(children)) return
439
+ for (const child of children) walkRowEl(child as ElementMeta, set)
440
+ }
441
+
442
+ function walkRowEl(node: ElementMeta, set: Set<string>): void {
443
+ if (node.type === 'field') {
444
+ const fieldType = String(node['fieldType'] ?? '')
445
+ if (fieldType === 'repeater' || fieldType === 'builder') return // nested array
446
+ if ((node as { collab?: boolean }).collab === false) return
447
+ const name = String(node['name'] ?? '')
448
+ if (name && ROW_TEXT_FIELD_TYPES.has(fieldType)) set.add(name)
449
+ return
450
+ }
451
+ const children = node.children
452
+ if (Array.isArray(children)) {
453
+ for (const child of children) walkRowEl(child as ElementMeta, set)
454
+ }
455
+ }
456
+
457
+ function walkBlocks(blocks: unknown, set: Set<string>): void {
458
+ if (!Array.isArray(blocks)) return
459
+ for (const block of blocks) {
460
+ const tpl = (block as { template?: unknown }).template
461
+ walkRow(tpl, set)
462
+ }
463
+ }
464
+ }
@@ -51,6 +51,8 @@ export {
51
51
  type FormCollabBindingFactoryArgs,
52
52
  type TextBinding,
53
53
  type TextDelta,
54
+ type RowsEvent,
55
+ type RowBindingApi,
54
56
  } from './FormCollabBindingRegistry.js'
55
57
  export {
56
58
  registerFieldPresenceComponent,
@@ -89,6 +91,7 @@ export {
89
91
  FormStateProvider,
90
92
  useFieldState,
91
93
  useFormState,
94
+ useRowBinding,
92
95
  type FormStateApi,
93
96
  type FormStateProviderProps,
94
97
  type UseFieldStateResult,