@pilotiq/pilotiq 0.12.0 → 0.13.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.
Files changed (71) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/dist/pageData/helpers.d.ts +16 -0
  4. package/dist/pageData/helpers.d.ts.map +1 -1
  5. package/dist/pageData/helpers.js +61 -1
  6. package/dist/pageData/helpers.js.map +1 -1
  7. package/dist/pageData.d.ts +1 -1
  8. package/dist/pageData.d.ts.map +1 -1
  9. package/dist/pageData.js +1 -1
  10. package/dist/pageData.js.map +1 -1
  11. package/dist/react/FormCollabBindingRegistry.d.ts +33 -98
  12. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  13. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  14. package/dist/react/FormStateContext.d.ts +1 -35
  15. package/dist/react/FormStateContext.d.ts.map +1 -1
  16. package/dist/react/FormStateContext.js +15 -92
  17. package/dist/react/FormStateContext.js.map +1 -1
  18. package/dist/react/RowCoordsContext.d.ts +19 -0
  19. package/dist/react/RowCoordsContext.d.ts.map +1 -0
  20. package/dist/react/RowCoordsContext.js +6 -0
  21. package/dist/react/RowCoordsContext.js.map +1 -0
  22. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  23. package/dist/react/fields/BuilderInput.js +78 -49
  24. package/dist/react/fields/BuilderInput.js.map +1 -1
  25. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  26. package/dist/react/fields/MarkdownInput.js +35 -125
  27. package/dist/react/fields/MarkdownInput.js.map +1 -1
  28. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  29. package/dist/react/fields/RepeaterInput.js +104 -60
  30. package/dist/react/fields/RepeaterInput.js.map +1 -1
  31. package/dist/react/fields/TextLikeInput.d.ts +11 -9
  32. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  33. package/dist/react/fields/TextLikeInput.js +59 -189
  34. package/dist/react/fields/TextLikeInput.js.map +1 -1
  35. package/dist/react/fields/repeaterReconcile.d.ts +66 -0
  36. package/dist/react/fields/repeaterReconcile.d.ts.map +1 -0
  37. package/dist/react/fields/repeaterReconcile.js +96 -0
  38. package/dist/react/fields/repeaterReconcile.js.map +1 -0
  39. package/dist/react/formStateHelpers.d.ts +0 -15
  40. package/dist/react/formStateHelpers.d.ts.map +1 -1
  41. package/dist/react/formStateHelpers.js +0 -91
  42. package/dist/react/formStateHelpers.js.map +1 -1
  43. package/dist/react/index.d.ts +1 -1
  44. package/dist/react/index.d.ts.map +1 -1
  45. package/dist/react/index.js.map +1 -1
  46. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
  47. package/dist/react/schemaRenderer/form/FormRenderer.js +10 -0
  48. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/pageData/helpers.ts +55 -1
  51. package/src/pageData.test.ts +67 -0
  52. package/src/pageData.ts +1 -0
  53. package/src/react/FormCollabBindingRegistry.ts +34 -91
  54. package/src/react/FormStateContext.tsx +14 -126
  55. package/src/react/RowCoordsContext.tsx +23 -0
  56. package/src/react/fields/BuilderInput.tsx +75 -39
  57. package/src/react/fields/MarkdownInput.tsx +42 -129
  58. package/src/react/fields/RepeaterInput.tsx +107 -48
  59. package/src/react/fields/TextLikeInput.tsx +67 -225
  60. package/src/react/fields/repeaterReconcile.test.ts +114 -0
  61. package/src/react/fields/repeaterReconcile.ts +104 -0
  62. package/src/react/formStateHelpers.test.ts +0 -99
  63. package/src/react/formStateHelpers.ts +0 -83
  64. package/src/react/index.ts +0 -2
  65. package/src/react/schemaRenderer/form/FormRenderer.tsx +10 -0
  66. package/dist/react/fields/textDelta.d.ts +0 -44
  67. package/dist/react/fields/textDelta.d.ts.map +0 -1
  68. package/dist/react/fields/textDelta.js +0 -80
  69. package/dist/react/fields/textDelta.js.map +0 -1
  70. package/src/react/fields/textDelta.test.ts +0 -141
  71. package/src/react/fields/textDelta.ts +0 -86
@@ -1,12 +1,12 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
2
2
  import type { ElementMeta } from '../../schema/Element.js'
3
- import type { TextBinding } from '../FormCollabBindingRegistry.js'
4
3
  import { useFieldState } from '../FormStateContext.js'
5
4
  import { useCollabRoom } from '../CollabRoomContext.js'
6
5
  import { getCollabTextRenderer, type CollabTextRenderer } from '../CollabTextRendererRegistry.js'
6
+ import { useRowCoords } from '../RowCoordsContext.js'
7
+ import { parseRowFieldPath } from '../formStateHelpers.js'
7
8
  import { Input } from '../ui/input.js'
8
9
  import { Textarea } from '../ui/textarea.js'
9
- import { computeDelta, preserveCursor } from './textDelta.js'
10
10
 
11
11
  /**
12
12
  * Bridge between controlled (FormStateProvider) and uncontrolled
@@ -15,15 +15,17 @@ import { computeDelta, preserveCursor } from './textDelta.js'
15
15
  * fires the live trigger on change/blur according to the field's `live`
16
16
  * config. Outside a controlled form, falls back to plain `defaultValue`.
17
17
  *
18
- * **Phase F.6character-level CRDT branch.** When a `<RecordCollabRoom>`
19
- * is mounted up-tree AND `@pilotiq-pro/collab`'s binding registered a
20
- * `TextBinding` for this field (text-shaped fieldType + `.collab() !== false`),
21
- * the input takes the `BoundTextInput` path: edits emit `TextDelta`s to
22
- * the binding's `Y.Text`, remote changes flow back via `observe`, and
23
- * cursor position survives both. The legacy whole-string LWW path
24
- * still runs for non-text fields, non-collab forms, and masked inputs
25
- * (mask + character-level CRDT is incompatible peers would see raw
26
- * keystrokes desynced from the rendered mask).
18
+ * **Collab branchTiptap-backed `Y.XmlFragment`.** When a
19
+ * `<RecordCollabRoom>` is mounted up-tree AND `@pilotiq/tiptap`'s
20
+ * `registerTiptap()` registered a collab text renderer, the input
21
+ * mounts the Tiptap-backed editor against a `Y.XmlFragment` keyed by
22
+ * either the bare field name (top-level) or
23
+ * `${arrayName}.${rowId}.${fieldName}` (Repeater / Builder row leaves
24
+ * via `useRowCoords()`). Selections anchor to `Y.RelativePosition` via
25
+ * y-prosemirror, so cursors survive both mid-word remote edits and
26
+ * concurrent inserts. Masked fields fall through to the legacy
27
+ * whole-string LWW path (mask + character-level CRDT is incompatible
28
+ * — peers would see raw keystrokes desynced from the rendered mask).
27
29
  */
28
30
  export function TextLikeInput({
29
31
  el, name, common, type, extraProps, multiline, applyMask,
@@ -42,6 +44,7 @@ export function TextLikeInput({
42
44
  const fs = useFieldState(name)
43
45
  const room = useCollabRoom()
44
46
  const collabRenderer = getCollabTextRenderer()
47
+ const rowCoords = useRowCoords()
45
48
  const liveCfg = el['live']
46
49
  const liveOpts = (typeof liveCfg === 'object' && liveCfg !== null
47
50
  ? liveCfg as { onBlur?: boolean; debounce?: number }
@@ -49,35 +52,48 @@ export function TextLikeInput({
49
52
  const onBlurMode = liveOpts.onBlur === true
50
53
  const mask = applyMask ?? identity
51
54
 
52
- // Phase F.6 character-level CRDT path. Masking is mutually exclusive
53
- // with character-level CRDT (peers would see raw keystrokes diverged
54
- // from the local mask render); masked fields fall through to LWW.
55
- // We read the mask from the field meta directly — `applyMask` is a
56
- // `useCallback`-wrapped fn that's *always* defined (identity when no
57
- // mask), so its truthiness can't gate the branch.
55
+ // Masking is mutually exclusive with character-level CRDT (peers would
56
+ // see raw keystrokes diverged from the local mask render); masked
57
+ // fields fall through to LWW. We read the mask from the field meta
58
+ // directly — `applyMask` is a `useCallback`-wrapped fn that's *always*
59
+ // defined (identity when no mask), so its truthiness can't gate the
60
+ // branch.
58
61
  const hasMask = typeof el['mask'] === 'string'
59
62
 
60
- // Phase B — Tiptap-backed plain-text editor for collab text fields.
61
- // When a `<RecordCollabRoom>` is mounted up-tree AND `@pilotiq/tiptap`'s
62
- // `registerTiptap()` registered a collab text renderer, take the new path:
63
- // the editor anchors selections to Yjs `RelativePosition` (via y-prosemirror)
64
- // instead of integer string offsets, fixing the cursor-jump + two-peer
65
- // concurrent-insert races that the legacy `Y.Text` + `computeDelta` path
66
- // can't resolve. Dotted-path row leaves (Repeater / Builder) stay on the
67
- // legacy `fs.textBinding` path — per-row collab editor support is a
68
- // separate follow-up.
63
+ // Collab branch — Tiptap-backed plain-text editor. Top-level fields
64
+ // use the bare `name` as the fragment-key; Repeater / Builder row
65
+ // leaves compose `${arrayName}.${rowId}.${fieldName}` from
66
+ // `useRowCoords()` so the Y.XmlFragment survives row reorders (keyed
67
+ // by the stable rowId, not the array index). The hidden FormData
68
+ // input keeps the original dotted path so submission lands on the
69
+ // server at the right slot.
70
+ //
71
+ // Dotted paths that don't match a row shape (no rowCoords OR
72
+ // `parseRowFieldPath` returns null — nested row arrays, malformed
73
+ // names) skip the collab path and fall through to the controlled /
74
+ // uncontrolled branches below.
69
75
  const fieldCollab = el['collab'] as boolean | undefined
76
+ const fragmentKey: string | null = (() => {
77
+ if (!name.includes('.')) return name
78
+ if (!rowCoords) return null
79
+ const parsed = parseRowFieldPath(name)
80
+ if (!parsed) return null
81
+ if (parsed.arrayName !== rowCoords.arrayName) return null
82
+ if (parsed.index !== rowCoords.rowIndex) return null
83
+ return `${rowCoords.arrayName}.${rowCoords.rowId}.${parsed.fieldName}`
84
+ })()
70
85
  if (
71
86
  room &&
72
87
  collabRenderer &&
73
88
  fieldCollab !== false &&
74
89
  !hasMask &&
75
- !name.includes('.')
90
+ fragmentKey !== null
76
91
  ) {
77
92
  return (
78
93
  <CollabTextField
79
94
  Renderer={collabRenderer}
80
- name={name}
95
+ fragmentKey={fragmentKey}
96
+ hiddenInputName={name}
81
97
  multiline={multiline}
82
98
  defaultValue={stringValue(common['defaultValue'])}
83
99
  {...(common['placeholder'] !== undefined ? { placeholder: String(common['placeholder']) } : {})}
@@ -90,21 +106,6 @@ export function TextLikeInput({
90
106
  )
91
107
  }
92
108
 
93
- if (fs.textBinding && !hasMask) {
94
- return (
95
- <BoundTextInput
96
- binding={fs.textBinding}
97
- name={name}
98
- triggerLive={fs.triggerLive}
99
- onBlurMode={onBlurMode}
100
- common={common}
101
- extraProps={extraProps}
102
- type={type}
103
- multiline={multiline}
104
- />
105
- )
106
- }
107
-
108
109
  if (fs.controlled) {
109
110
  const ctxValue = fs.value !== undefined && fs.value !== null ? String(fs.value) : ''
110
111
  const onChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
@@ -144,174 +145,7 @@ export function TextLikeInput({
144
145
  }
145
146
 
146
147
  /**
147
- * Phase F.6 CRDT-bound text input. Owns its own controlled state
148
- * because the binding's `Y.Text` is the source of truth (not the
149
- * form's `values` map). Mirrors every committed value back into the
150
- * form context via `fs.setValue` so submission / live re-resolve see
151
- * the latest string.
152
- *
153
- * Lifecycle:
154
- * - Mount: seed local state from `binding.read()`; mirror it into
155
- * the form's `values` map.
156
- * - Local edit: compute a `TextDelta` (insert / delete / replace)
157
- * from the before/after strings and `applyDelta` to the binding.
158
- * Eagerly update local state in the same React render so the
159
- * controlled input doesn't lag the keystroke.
160
- * - Remote edit: `binding.observe` fires with the post-change
161
- * string; we replace local state and best-effort preserve the
162
- * local cursor via `preserveCursor`. The local-echo of our own
163
- * `applyDelta` is collapsed by the value-equality check.
164
- * - IME composition: `applyDelta` is deferred to `compositionend`
165
- * so the binding never sees intermediate composing chars (which
166
- * would emit one delta per keystroke and confuse downstream
167
- * observers).
168
- */
169
- function BoundTextInput({
170
- binding, name, triggerLive, onBlurMode, common, extraProps, type, multiline,
171
- }: {
172
- binding: TextBinding
173
- name: string
174
- triggerLive: (valueOverride?: unknown) => void
175
- onBlurMode: boolean
176
- common: Record<string, unknown>
177
- extraProps: Record<string, unknown>
178
- type: string
179
- multiline: boolean
180
- }): React.ReactElement {
181
- const fs = useFieldState(name)
182
- // SSR-rendered default. Captured once at mount; used as display
183
- // fallback while the room's `Y.Text` is still empty (the seed race
184
- // for Y.Text isn't safe across concurrent first-mounters, so no peer
185
- // populates it client-side — see `@pilotiq-pro/collab` for the
186
- // rationale). First user edit emits a replace-from-empty delta that
187
- // atomically lifts the displayed value into the CRDT.
188
- // eslint-disable-next-line react-hooks/exhaustive-deps
189
- const fallback = useMemo(() => stringValue(fs.value), [])
190
- const [value, setValueLocal] = useState<string>(() => binding.read() || fallback)
191
- const valueRef = useRef<string>(value)
192
- const isComposing = useRef<boolean>(false)
193
- const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
194
-
195
- useEffect(() => { valueRef.current = value }, [value])
196
-
197
- // Stable ref to the form-mirror writer so the observer effect below
198
- // doesn't tear down on every render (fs.setValue is a fresh arrow on
199
- // every useFieldState call).
200
- const mirrorRef = useRef<(v: string) => void>(() => {})
201
- useEffect(() => {
202
- mirrorRef.current = (v: string): void => { fs.setValue(v) }
203
- })
204
-
205
- // On mount / binding swap: read the binding's current state. If
206
- // non-empty (i.e. someone else has already typed), display it and
207
- // mirror into the form values map. If empty, leave the fallback
208
- // showing — no client-side seed (see file-header comment).
209
- useEffect(() => {
210
- const initial = binding.read()
211
- if (initial.length > 0) {
212
- setValueLocal(initial)
213
- valueRef.current = initial
214
- mirrorRef.current(initial)
215
- }
216
- }, [binding])
217
-
218
- // Subscribe to text-CRDT changes. Yjs fires this for BOTH local and
219
- // remote transactions — local echoes are collapsed by the
220
- // `next === prev` guard.
221
- useEffect(() => {
222
- const unsubscribe = binding.observe((next) => {
223
- const prev = valueRef.current
224
- if (next === prev) return
225
- const el = inputRef.current
226
- const cursor = el?.selectionStart ?? next.length
227
- const restored = preserveCursor(prev, next, cursor)
228
- setValueLocal(next)
229
- valueRef.current = next
230
- mirrorRef.current(next)
231
- // Defer cursor restore until after React commits. Only reapply
232
- // when the input is still focused — yanking the selection on a
233
- // blurred field would steal focus across the page.
234
- requestAnimationFrame(() => {
235
- if (!el) return
236
- if (document.activeElement !== el) return
237
- try { el.setSelectionRange(restored, restored) } catch { /* setSelectionRange unsupported on some input types — defensive */ }
238
- })
239
- })
240
- return unsubscribe
241
- }, [binding])
242
-
243
- const commitDelta = useCallback((after: string): void => {
244
- // Compute the delta against the binding's *current* Y.Text contents
245
- // — not the renderer's `before` ref. The two can diverge in three
246
- // cases that all converge correctly under this approach:
247
- // 1. First edit when Y.Text is empty: delta = `insert@0 <whole>`,
248
- // which atomically lifts the displayed fallback into the CRDT
249
- // without a separate seed op.
250
- // 2. After a remote-applied update: Y.Text holds the peer's value;
251
- // computing against it avoids "ghost" deltas that re-emit ops
252
- // against a stale local ref.
253
- // 3. After a server-resolve `triggerLive` replace: same as (2).
254
- const before = binding.read()
255
- if (after === before) return
256
- const delta = computeDelta(before, after)
257
- if (!delta) return
258
- // Pre-stamp `valueRef.current = after` BEFORE `applyDelta`. Y.Text's
259
- // observe fires synchronously inside `applyDelta` for our own write,
260
- // so without this the observer would see `prev=before, next=after`
261
- // and run `preserveCursor` — which is designed for *remote* edits
262
- // and clobbers the user's caret on local typing (typed '1' at pos 0
263
- // would jump cursor forward by delta-length and the next keystroke
264
- // would insert at the wrong index, producing scrambled output).
265
- // With `valueRef` already at `after`, the observer's `next === prev`
266
- // short-circuit fires and the cursor is left alone for local echoes.
267
- valueRef.current = after
268
- binding.applyDelta(delta)
269
- setValueLocal(after)
270
- mirrorRef.current(after)
271
- if (!onBlurMode) triggerLive(after)
272
- }, [binding, onBlurMode, triggerLive])
273
-
274
- const onChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
275
- if (isComposing.current) {
276
- // IME mid-composition — paint locally, hold the delta until commit.
277
- setValueLocal(e.target.value)
278
- return
279
- }
280
- commitDelta(e.target.value)
281
- }
282
-
283
- const onCompositionStart = (): void => { isComposing.current = true }
284
- const onCompositionEnd = (e: React.CompositionEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
285
- isComposing.current = false
286
- commitDelta(e.currentTarget.value)
287
- }
288
-
289
- const onBlur = (): void => {
290
- if (onBlurMode) triggerLive(valueRef.current)
291
- }
292
-
293
- const setRef = (el: HTMLInputElement | HTMLTextAreaElement | null): void => {
294
- inputRef.current = el
295
- }
296
-
297
- const props = {
298
- ...common,
299
- ...extraProps,
300
- defaultValue: undefined,
301
- value,
302
- onChange,
303
- onBlur,
304
- onCompositionStart,
305
- onCompositionEnd,
306
- ref: setRef,
307
- }
308
-
309
- if (multiline) return <Textarea {...(props as React.ComponentProps<typeof Textarea>)} />
310
- return <Input {...(props as React.ComponentProps<typeof Input>)} type={type} />
311
- }
312
-
313
- /**
314
- * Phase B — wrapper around the registered Tiptap-backed collab editor.
148
+ * Wrapper around the registered Tiptap-backed collab editor.
315
149
  * Owns the local text mirror so the hidden `<input>` always carries the
316
150
  * editor's current value for FormData submission. When `FormStateProvider`
317
151
  * is mounted up-tree, also mirrors every update into the values map via
@@ -322,21 +156,29 @@ function BoundTextInput({
322
156
  * editor handles composition natively and y-prosemirror anchors selections
323
157
  * to `Yjs.RelativePosition`, so the cursor survives concurrent + mid-word
324
158
  * remote edits without any client-side bookkeeping.
159
+ *
160
+ * `fragmentKey` and `hiddenInputName` diverge for row-text leaves (Phase
161
+ * 1 of collab-row-text-tiptap-backed.md): the renderer's Y.XmlFragment is
162
+ * keyed by `${arrayName}.${rowId}.${fieldName}` so it survives row
163
+ * reorders, while the hidden FormData input keeps the dotted path
164
+ * (`items.0.title`) so submission lands at the right server-side slot.
165
+ * For top-level fields the two are identical.
325
166
  */
326
167
  function CollabTextField({
327
- Renderer, name, multiline, defaultValue, placeholder, disabled,
168
+ Renderer, fragmentKey, hiddenInputName, multiline, defaultValue, placeholder, disabled,
328
169
  triggerLive, setValue, controlled, onBlurMode,
329
170
  }: {
330
- Renderer: CollabTextRenderer
331
- name: string
332
- multiline: boolean
333
- defaultValue: string
334
- placeholder?: string
335
- disabled: boolean
336
- triggerLive: (valueOverride?: unknown) => void
337
- setValue: (v: unknown) => void
338
- controlled: boolean
339
- onBlurMode: boolean
171
+ Renderer: CollabTextRenderer
172
+ fragmentKey: string
173
+ hiddenInputName: string
174
+ multiline: boolean
175
+ defaultValue: string
176
+ placeholder?: string
177
+ disabled: boolean
178
+ triggerLive: (valueOverride?: unknown) => void
179
+ setValue: (v: unknown) => void
180
+ controlled: boolean
181
+ onBlurMode: boolean
340
182
  }): React.ReactElement {
341
183
  const [text, setText] = useState<string>(defaultValue)
342
184
  const textRef = useRef(text)
@@ -376,9 +218,9 @@ function CollabTextField({
376
218
 
377
219
  return (
378
220
  <>
379
- <input type="hidden" name={name} value={text} />
221
+ <input type="hidden" name={hiddenInputName} value={text} />
380
222
  <Renderer
381
- name={name}
223
+ name={fragmentKey}
382
224
  multiline={multiline}
383
225
  defaultValue={defaultValue}
384
226
  {...(placeholder !== undefined ? { placeholder } : {})}
@@ -0,0 +1,114 @@
1
+ import { describe, it, before, after, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ computeReconcilePlan,
6
+ markSubmitForReconcile,
7
+ consumeReconcileFlag,
8
+ } from './repeaterReconcile.js'
9
+
10
+ describe('computeReconcilePlan', () => {
11
+ it('returns empty plan when current and authoritative match', () => {
12
+ const plan = computeReconcilePlan({
13
+ current: ['a', 'b', 'c'],
14
+ authoritative: ['a', 'b', 'c'],
15
+ })
16
+ assert.deepEqual(plan.toRemove, [])
17
+ assert.deepEqual(plan.toAdd, [])
18
+ })
19
+
20
+ it('flags orphan CRDT rows as toRemove (PK-switch happy path)', () => {
21
+ // Submitting tab reloaded — server returned the new DB PK; CRDT
22
+ // still carries the renderer-minted UUID from the just-saved row.
23
+ const plan = computeReconcilePlan({
24
+ current: ['uuid-foo', '42'],
25
+ authoritative: ['42'],
26
+ })
27
+ assert.deepEqual(plan.toRemove, ['uuid-foo'])
28
+ assert.deepEqual(plan.toAdd, [])
29
+ })
30
+
31
+ it('flags missing CRDT rows as toAdd (raw-SQL-seeded record)', () => {
32
+ // First peer to open a record whose DB rows weren't seeded into the
33
+ // Y.Doc (no `seedRowArraysFromRecord` coverage for relationship-
34
+ // backed fields). Reconciler ensures CRDT mirrors initialRows.
35
+ const plan = computeReconcilePlan({
36
+ current: [],
37
+ authoritative: ['42', '43'],
38
+ })
39
+ assert.deepEqual(plan.toRemove, [])
40
+ assert.deepEqual(plan.toAdd, ['42', '43'])
41
+ })
42
+
43
+ it('handles both directions in a single pass', () => {
44
+ const plan = computeReconcilePlan({
45
+ current: ['uuid-foo', 'uuid-bar', '42'],
46
+ authoritative: ['42', '43'],
47
+ })
48
+ assert.deepEqual(plan.toRemove, ['uuid-foo', 'uuid-bar'])
49
+ assert.deepEqual(plan.toAdd, ['43'])
50
+ })
51
+
52
+ it('preserves order from inputs in toRemove / toAdd', () => {
53
+ const plan = computeReconcilePlan({
54
+ current: ['z', 'a', 'm'],
55
+ authoritative: ['a', 'b', 'c'],
56
+ })
57
+ // toRemove walks current in order; toAdd walks authoritative in order.
58
+ // Order-stability matters because reconciler applies them sequentially
59
+ // and we want deterministic test snapshots.
60
+ assert.deepEqual(plan.toRemove, ['z', 'm'])
61
+ assert.deepEqual(plan.toAdd, ['b', 'c'])
62
+ })
63
+ })
64
+
65
+ describe('markSubmitForReconcile / consumeReconcileFlag', () => {
66
+ // Minimal in-memory sessionStorage stub — Node lacks one, and we
67
+ // want to avoid bringing in jsdom for a flag-roundtrip test.
68
+ const realSessionStorage = (globalThis as { sessionStorage?: Storage }).sessionStorage
69
+ const store: Map<string, string> = new Map()
70
+
71
+ before(() => {
72
+ ;(globalThis as { sessionStorage?: Storage }).sessionStorage = {
73
+ get length() { return store.size },
74
+ key: (i: number) => Array.from(store.keys())[i] ?? null,
75
+ getItem: (k: string) => store.has(k) ? store.get(k)! : null,
76
+ setItem: (k: string, v: string) => { store.set(k, v) },
77
+ removeItem: (k: string) => { store.delete(k) },
78
+ clear: () => { store.clear() },
79
+ } as Storage
80
+ })
81
+
82
+ after(() => {
83
+ if (realSessionStorage === undefined) {
84
+ delete (globalThis as { sessionStorage?: Storage }).sessionStorage
85
+ } else {
86
+ (globalThis as { sessionStorage?: Storage }).sessionStorage = realSessionStorage
87
+ }
88
+ })
89
+
90
+ beforeEach(() => { store.clear() })
91
+
92
+ it('returns false when no flag has been set', () => {
93
+ assert.equal(consumeReconcileFlag('form-1'), false)
94
+ })
95
+
96
+ it('round-trips a flag and clears on first consume', () => {
97
+ markSubmitForReconcile('form-1')
98
+ assert.equal(consumeReconcileFlag('form-1'), true)
99
+ // Second read: flag was cleared on the first consume.
100
+ assert.equal(consumeReconcileFlag('form-1'), false)
101
+ })
102
+
103
+ it('scopes the flag per formId', () => {
104
+ markSubmitForReconcile('form-1')
105
+ assert.equal(consumeReconcileFlag('form-2'), false)
106
+ assert.equal(consumeReconcileFlag('form-1'), true)
107
+ })
108
+
109
+ it('no-ops on empty formId (mark and consume both)', () => {
110
+ markSubmitForReconcile('')
111
+ assert.equal(store.size, 0)
112
+ assert.equal(consumeReconcileFlag(''), false)
113
+ })
114
+ })
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Phase A of the `Repeater.relationship` PK-switch reconciliation
3
+ * (see `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`).
4
+ *
5
+ * When a parent form submit creates new relationship-backed rows, the
6
+ * server assigns each child a DB primary key — but the row's `__id` in
7
+ * the row CRDT is still the renderer-minted UUID from the local session.
8
+ * After redirect, the submitting tab's pageData carries `__id = String(pk)`
9
+ * while CRDT still has the UUID, so the renderer ends up showing the
10
+ * same row twice (DB PK from initialRows + orphan UUID from CRDT).
11
+ *
12
+ * Phase A fix: the submitting tab marks itself for a one-shot CRDT
13
+ * reconcile on the next mount via a per-formId sessionStorage flag.
14
+ * `RepeaterInput` / `BuilderInput` read the flag on mount and, when set,
15
+ * snapshot the row CRDT after a short settle (waiting for WS sync) and
16
+ * reconcile against `initialRows` — removing orphan CRDT rows not in
17
+ * the form's authoritative data, and adding missing CRDT rows (rare,
18
+ * happens when the row was DB-seeded outside the collab session).
19
+ *
20
+ * The flag is scoped per-tab via sessionStorage, so other peers' tabs
21
+ * never run the reconciler — preserving their in-flight edits.
22
+ *
23
+ * Phase B (server-side rename via the @rudderjs/sync Y.Doc seam) will
24
+ * extend this to other peers without requiring them to reload.
25
+ */
26
+
27
+ const STORAGE_PREFIX = 'pilotiq.repeaterReconcile.'
28
+
29
+ function storageKey(formId: string): string {
30
+ return STORAGE_PREFIX + formId
31
+ }
32
+
33
+ /**
34
+ * Called by `FormRenderer` on submit success. Records that this tab
35
+ * has just persisted the form, so the next mount of any Repeater /
36
+ * Builder under the same form runs the PK-switch reconciler. No-op
37
+ * when `formId` is empty or `sessionStorage` is unavailable (SSR).
38
+ */
39
+ export function markSubmitForReconcile(formId: string): void {
40
+ if (!formId) return
41
+ if (typeof sessionStorage === 'undefined') return
42
+ try {
43
+ sessionStorage.setItem(storageKey(formId), '1')
44
+ } catch {
45
+ // Quota exceeded / disabled — silently skip. Reconciliation is
46
+ // an optimization, not a correctness requirement.
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Called by `RepeaterInput` / `BuilderInput` on mount. Returns `true`
52
+ * iff the form was just submitted in this tab AND clears the flag so
53
+ * subsequent mounts no-op. Idempotent across multiple Repeater/Builder
54
+ * fields on the same form — the FIRST reader clears the flag, so
55
+ * siblings see `false`. To avoid that, both fields call this helper at
56
+ * the same mount tick — for v1 we accept the limitation: only the first
57
+ * Repeater on the form runs the reconciler; siblings don't.
58
+ *
59
+ * If a future need surfaces (multiple relationship-backed Repeaters on
60
+ * the same form), switch to a per-field flag keyed by `formId.fieldName`
61
+ * or have the FormRenderer dispatch a custom event instead.
62
+ */
63
+ export function consumeReconcileFlag(formId: string): boolean {
64
+ if (!formId) return false
65
+ if (typeof sessionStorage === 'undefined') return false
66
+ try {
67
+ const v = sessionStorage.getItem(storageKey(formId))
68
+ if (v !== '1') return false
69
+ sessionStorage.removeItem(storageKey(formId))
70
+ return true
71
+ } catch {
72
+ return false
73
+ }
74
+ }
75
+
76
+ export interface ReconcileInputs {
77
+ /** Current CRDT row id order (post-WS-sync). */
78
+ current: readonly string[]
79
+ /** Authoritative row id list from server-rendered initialRows. */
80
+ authoritative: readonly string[]
81
+ }
82
+
83
+ export interface ReconcilePlan {
84
+ /** Row ids present in CRDT but not in initialRows — orphan UUIDs. */
85
+ toRemove: string[]
86
+ /** Row ids present in initialRows but not in CRDT — DB rows not yet
87
+ * in the room (rare; only happens when DB was seeded outside collab). */
88
+ toAdd: string[]
89
+ }
90
+
91
+ /**
92
+ * Pure helper: compute the symmetric difference. Exported separately so
93
+ * unit tests don't need a DOM / sessionStorage shim to verify the
94
+ * reconciliation arithmetic.
95
+ */
96
+ export function computeReconcilePlan({ current, authoritative }: ReconcileInputs): ReconcilePlan {
97
+ const currentSet = new Set(current)
98
+ const authSet = new Set(authoritative)
99
+ const toRemove: string[] = []
100
+ const toAdd: string[] = []
101
+ for (const id of current) if (!authSet.has(id)) toRemove.push(id)
102
+ for (const id of authoritative) if (!currentSet.has(id)) toAdd.push(id)
103
+ return { toRemove, toAdd }
104
+ }