@pilotiq/pilotiq 0.13.0 → 0.14.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.
Files changed (75) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/dist/Page.d.ts +40 -0
  4. package/dist/Page.d.ts.map +1 -1
  5. package/dist/Page.js +32 -0
  6. package/dist/Page.js.map +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/pageData/helpers.d.ts +16 -0
  11. package/dist/pageData/helpers.d.ts.map +1 -1
  12. package/dist/pageData/helpers.js +61 -1
  13. package/dist/pageData/helpers.js.map +1 -1
  14. package/dist/pageData/navigation.d.ts +15 -1
  15. package/dist/pageData/navigation.d.ts.map +1 -1
  16. package/dist/pageData/navigation.js +15 -0
  17. package/dist/pageData/navigation.js.map +1 -1
  18. package/dist/pageData.d.ts +1 -1
  19. package/dist/pageData.d.ts.map +1 -1
  20. package/dist/pageData.js +1 -1
  21. package/dist/pageData.js.map +1 -1
  22. package/dist/react/AppShell.d.ts +5 -0
  23. package/dist/react/AppShell.d.ts.map +1 -1
  24. package/dist/react/AppShell.js +2 -1
  25. package/dist/react/AppShell.js.map +1 -1
  26. package/dist/react/CustomPageWrapperGate.d.ts +33 -0
  27. package/dist/react/CustomPageWrapperGate.d.ts.map +1 -0
  28. package/dist/react/CustomPageWrapperGate.js +50 -0
  29. package/dist/react/CustomPageWrapperGate.js.map +1 -0
  30. package/dist/react/CustomPageWrapperRegistry.d.ts +34 -0
  31. package/dist/react/CustomPageWrapperRegistry.d.ts.map +1 -0
  32. package/dist/react/CustomPageWrapperRegistry.js +19 -0
  33. package/dist/react/CustomPageWrapperRegistry.js.map +1 -0
  34. package/dist/react/FormCollabBindingRegistry.d.ts +16 -0
  35. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  36. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  37. package/dist/react/FormStateContext.d.ts.map +1 -1
  38. package/dist/react/FormStateContext.js +8 -1
  39. package/dist/react/FormStateContext.js.map +1 -1
  40. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  41. package/dist/react/fields/BuilderInput.js +64 -40
  42. package/dist/react/fields/BuilderInput.js.map +1 -1
  43. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  44. package/dist/react/fields/RepeaterInput.js +78 -43
  45. package/dist/react/fields/RepeaterInput.js.map +1 -1
  46. package/dist/react/fields/repeaterReconcile.d.ts +66 -0
  47. package/dist/react/fields/repeaterReconcile.d.ts.map +1 -0
  48. package/dist/react/fields/repeaterReconcile.js +96 -0
  49. package/dist/react/fields/repeaterReconcile.js.map +1 -0
  50. package/dist/react/index.d.ts +2 -0
  51. package/dist/react/index.d.ts.map +1 -1
  52. package/dist/react/index.js +2 -0
  53. package/dist/react/index.js.map +1 -1
  54. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
  55. package/dist/react/schemaRenderer/form/FormRenderer.js +10 -0
  56. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/Page.test.ts +64 -0
  59. package/src/Page.ts +60 -0
  60. package/src/index.ts +1 -1
  61. package/src/pageData/helpers.ts +55 -1
  62. package/src/pageData/navigation.ts +31 -1
  63. package/src/pageData.test.ts +109 -0
  64. package/src/pageData.ts +1 -0
  65. package/src/react/AppShell.tsx +12 -1
  66. package/src/react/CustomPageWrapperGate.tsx +69 -0
  67. package/src/react/CustomPageWrapperRegistry.ts +45 -0
  68. package/src/react/FormCollabBindingRegistry.ts +17 -0
  69. package/src/react/FormStateContext.tsx +8 -1
  70. package/src/react/fields/BuilderInput.tsx +53 -29
  71. package/src/react/fields/RepeaterInput.tsx +66 -32
  72. package/src/react/fields/repeaterReconcile.test.ts +114 -0
  73. package/src/react/fields/repeaterReconcile.ts +104 -0
  74. package/src/react/index.ts +10 -0
  75. package/src/react/schemaRenderer/form/FormRenderer.tsx +10 -0
@@ -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
+ }
@@ -78,6 +78,16 @@ export {
78
78
  RecordWrapperGate,
79
79
  type RecordWrapperGateProps,
80
80
  } from './RecordWrapperGate.js'
81
+ export {
82
+ registerCustomPageWrapper,
83
+ getCustomPageWrapper,
84
+ type CustomPageWrapperProps,
85
+ } from './CustomPageWrapperRegistry.js'
86
+ export {
87
+ CustomPageWrapperGate,
88
+ type CustomPageWrapperGateProps,
89
+ type PageCollabMap,
90
+ } from './CustomPageWrapperGate.js'
81
91
  export {
82
92
  parseRecordPageUrl,
83
93
  parseRecordEditUrl,
@@ -6,6 +6,7 @@ import { useCollabRoom } from '../../CollabRoomContext.js'
6
6
  import { getFormCollabBinding } from '../../FormCollabBindingRegistry.js'
7
7
  import { useNavigate } from '../../navigate.js'
8
8
  import { useToast } from '../../Toaster.js'
9
+ import { markSubmitForReconcile } from '../../fields/repeaterReconcile.js'
9
10
  import { renderField } from './renderField.js'
10
11
 
11
12
  // ─── Form ───────────────────────────────────────────────────
@@ -111,6 +112,15 @@ export function FormRenderer({
111
112
  }
112
113
 
113
114
  // Success — drain notifications and SPA-navigate to the redirect.
115
+ //
116
+ // Before navigating, mark this tab for the relationship-backed
117
+ // Repeater/Builder PK-switch reconciler. The next mount of any
118
+ // child Repeater/Builder under this formId will run a one-shot
119
+ // CRDT reconcile to drop orphan UUIDs whose rows just persisted
120
+ // under a fresh DB PK. See
121
+ // `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`
122
+ // (Phase A).
123
+ markSubmitForReconcile(formId)
114
124
  const notifs = (data as { notifications?: NotificationMeta[] }).notifications
115
125
  if (notifs && notifs.length > 0) for (const n of notifs) notify(n)
116
126
  const redirect = String((data as { redirect?: string }).redirect ?? '')