@kyneta/yjs-schema 1.6.0 → 1.7.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.
@@ -0,0 +1,227 @@
1
+ // materialize — Tests for materializeYjsShadow.
2
+ //
3
+ // Validates that the Yjs→PlainState materialization produces correct
4
+ // plain JS objects for all supported schema types: text, scalar,
5
+ // sequence, nested struct, and empty documents.
6
+ //
7
+ // Yjs does not support counter, tree, or movableList — those are skipped.
8
+
9
+ import type { ProductSchema, SchemaBinding } from "@kyneta/schema"
10
+ import {
11
+ BACKING_DOC,
12
+ change,
13
+ createDoc,
14
+ deriveSchemaBinding,
15
+ KIND,
16
+ Schema,
17
+ type SchemaNode,
18
+ SUBSTRATE,
19
+ } from "@kyneta/schema"
20
+ import { describe, expect, it } from "vitest"
21
+ import type * as Y from "yjs"
22
+ import { yjs } from "../bind-yjs.js"
23
+ import { materializeYjsShadow } from "../materialize.js"
24
+
25
+ // ===========================================================================
26
+ // Helpers
27
+ // ===========================================================================
28
+
29
+ function trivialBinding(schema: SchemaNode): SchemaBinding {
30
+ if (schema[KIND] === "product") {
31
+ return deriveSchemaBinding(schema as ProductSchema, {})
32
+ }
33
+ return { forward: new Map(), inverse: new Map() }
34
+ }
35
+
36
+ function getYDoc(docRef: unknown): Y.Doc {
37
+ const substrate = (docRef as any)[SUBSTRATE]
38
+ return (substrate as any)[BACKING_DOC] as Y.Doc
39
+ }
40
+
41
+ // ===========================================================================
42
+ // Test schemas
43
+ // ===========================================================================
44
+
45
+ const TextSchema = Schema.struct({
46
+ title: Schema.text(),
47
+ })
48
+
49
+ const ScalarSchema = Schema.struct({
50
+ name: Schema.string(),
51
+ age: Schema.number(),
52
+ active: Schema.boolean(),
53
+ })
54
+
55
+ const SequenceSchema = Schema.struct({
56
+ items: Schema.list(Schema.string()),
57
+ })
58
+
59
+ const NestedSchema = Schema.struct({
60
+ meta: Schema.struct({
61
+ author: Schema.string(),
62
+ version: Schema.number(),
63
+ }),
64
+ })
65
+
66
+ const FullSchema = Schema.struct({
67
+ title: Schema.text(),
68
+ theme: Schema.string(),
69
+ tags: Schema.list(Schema.string()),
70
+ settings: Schema.struct({
71
+ darkMode: Schema.boolean(),
72
+ fontSize: Schema.number(),
73
+ }),
74
+ })
75
+
76
+ // ===========================================================================
77
+ // Tests
78
+ // ===========================================================================
79
+
80
+ describe("materializeYjsShadow", () => {
81
+ it("materializes text fields", () => {
82
+ const doc = createDoc(yjs.bind(TextSchema))
83
+ change(doc, (d: any) => {
84
+ d.title.insert(0, "hello")
85
+ })
86
+
87
+ const yDoc = getYDoc(doc)
88
+ const binding = trivialBinding(TextSchema)
89
+ const result = materializeYjsShadow(yDoc, TextSchema, binding)
90
+
91
+ expect(result).toEqual({ title: "hello" })
92
+ })
93
+
94
+ it("materializes scalar fields", () => {
95
+ const doc = createDoc(yjs.bind(ScalarSchema))
96
+ change(doc, (d: any) => {
97
+ d.name.set("Alice")
98
+ d.age.set(30)
99
+ d.active.set(true)
100
+ })
101
+
102
+ const yDoc = getYDoc(doc)
103
+ const binding = trivialBinding(ScalarSchema)
104
+ const result = materializeYjsShadow(yDoc, ScalarSchema, binding)
105
+
106
+ expect(result).toEqual({ name: "Alice", age: 30, active: true })
107
+ })
108
+
109
+ it("materializes sequence fields", () => {
110
+ const doc = createDoc(yjs.bind(SequenceSchema))
111
+ // Separate change() calls for list pushes to preserve order
112
+ // (Yjs reverses order within a single transaction)
113
+ change(doc, (d: any) => d.items.push("alpha"))
114
+ change(doc, (d: any) => d.items.push("beta"))
115
+ change(doc, (d: any) => d.items.push("gamma"))
116
+
117
+ const yDoc = getYDoc(doc)
118
+ const binding = trivialBinding(SequenceSchema)
119
+ const result = materializeYjsShadow(yDoc, SequenceSchema, binding)
120
+
121
+ expect(result).toEqual({ items: ["alpha", "beta", "gamma"] })
122
+ })
123
+
124
+ it("materializes nested struct fields", () => {
125
+ const doc = createDoc(yjs.bind(NestedSchema))
126
+ change(doc, (d: any) => {
127
+ d.meta.author.set("Bob")
128
+ d.meta.version.set(42)
129
+ })
130
+
131
+ const yDoc = getYDoc(doc)
132
+ const binding = trivialBinding(NestedSchema)
133
+ const result = materializeYjsShadow(yDoc, NestedSchema, binding)
134
+
135
+ expect(result).toEqual({
136
+ meta: { author: "Bob", version: 42 },
137
+ })
138
+ })
139
+
140
+ it("materializes empty document to structural zeros", () => {
141
+ const doc = createDoc(yjs.bind(FullSchema))
142
+
143
+ const yDoc = getYDoc(doc)
144
+ const binding = trivialBinding(FullSchema)
145
+ const result = materializeYjsShadow(yDoc, FullSchema, binding)
146
+
147
+ expect(result).toEqual({
148
+ title: "",
149
+ theme: "",
150
+ tags: [],
151
+ settings: { darkMode: false, fontSize: 0 },
152
+ })
153
+ })
154
+
155
+ it("uses raw field names, not identity hashes", () => {
156
+ const doc = createDoc(yjs.bind(TextSchema))
157
+ change(doc, (d: any) => {
158
+ d.title.insert(0, "test")
159
+ })
160
+
161
+ const yDoc = getYDoc(doc)
162
+ const binding = trivialBinding(TextSchema)
163
+ const result = materializeYjsShadow(yDoc, TextSchema, binding)
164
+
165
+ // Keys should be the raw field name "title", not an identity hash
166
+ const keys = Object.keys(result as Record<string, unknown>)
167
+ expect(keys).toContain("title")
168
+ expect(keys).toHaveLength(1)
169
+ // No key should look like a hash (long alphanumeric string)
170
+ for (const key of keys) {
171
+ expect(key).toBe("title")
172
+ }
173
+ })
174
+
175
+ it("materializes a complex document with multiple field types", () => {
176
+ const doc = createDoc(yjs.bind(FullSchema))
177
+
178
+ change(doc, (d: any) => {
179
+ d.title.insert(0, "My Doc")
180
+ d.theme.set("dark")
181
+ d.settings.darkMode.set(true)
182
+ d.settings.fontSize.set(16)
183
+ })
184
+ change(doc, (d: any) => d.tags.push("important"))
185
+ change(doc, (d: any) => d.tags.push("urgent"))
186
+
187
+ const yDoc = getYDoc(doc)
188
+ const binding = trivialBinding(FullSchema)
189
+ const result = materializeYjsShadow(yDoc, FullSchema, binding)
190
+
191
+ expect(result).toEqual({
192
+ title: "My Doc",
193
+ theme: "dark",
194
+ tags: ["important", "urgent"],
195
+ settings: { darkMode: true, fontSize: 16 },
196
+ })
197
+ })
198
+
199
+ it("materializes without binding (undefined binding)", () => {
200
+ const doc = createDoc(yjs.bind(TextSchema))
201
+ change(doc, (d: any) => {
202
+ d.title.insert(0, "no binding")
203
+ })
204
+
205
+ const yDoc = getYDoc(doc)
206
+ // Pass no binding — materialize should still work
207
+ const result = materializeYjsShadow(yDoc, TextSchema)
208
+
209
+ // Without binding, keys may be identity hashes — but the values
210
+ // should still be correct and the result should be an object.
211
+ expect(result).toBeDefined()
212
+ expect(typeof result).toBe("object")
213
+ })
214
+
215
+ it("nested nullable materializes to null on fresh doc", () => {
216
+ const schema = Schema.struct({
217
+ settings: Schema.struct({
218
+ theme: Schema.string().nullable(),
219
+ }),
220
+ })
221
+ const doc = createDoc(yjs.bind(schema))
222
+ const yDoc = getYDoc(doc)
223
+ const binding = trivialBinding(schema)
224
+ const result = materializeYjsShadow(yDoc, schema, binding)
225
+ expect(result).toEqual({ settings: { theme: null } })
226
+ })
227
+ })
@@ -20,7 +20,7 @@ import {
20
20
  import {
21
21
  type PositionTestEnv,
22
22
  positionConformance,
23
- } from "@kyneta/schema/src/__tests__/position-conformance.js"
23
+ } from "@kyneta/schema/testing"
24
24
  import { describe, expect, it } from "vitest"
25
25
  import * as Y from "yjs"
26
26
  import { ensureContainers } from "../populate.js"
@@ -118,7 +118,7 @@ describe("YjsPosition: concurrent edits", () => {
118
118
  // --- Doc 2: fork from doc1's state ---
119
119
  const doc2 = new Y.Doc()
120
120
  Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
121
- ensureContainers(doc2, TextSchema, true)
121
+ ensureContainers(doc2, TextSchema)
122
122
  const substrate2 = createYjsSubstrate(doc2, TextSchema)
123
123
  const ref2 = createRef(TextSchema, substrate2) as any
124
124
 
@@ -175,7 +175,7 @@ describe("YjsPosition: concurrent edits", () => {
175
175
  // --- Doc 2: fork ---
176
176
  const doc2 = new Y.Doc()
177
177
  Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
178
- ensureContainers(doc2, TextSchema, true)
178
+ ensureContainers(doc2, TextSchema)
179
179
  const substrate2 = createYjsSubstrate(doc2, TextSchema)
180
180
  const ref2 = createRef(TextSchema, substrate2) as any
181
181
 
@@ -229,7 +229,7 @@ describe("YjsPosition: concurrent edits", () => {
229
229
  // --- Doc 2: fork ---
230
230
  const doc2 = new Y.Doc()
231
231
  Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
232
- ensureContainers(doc2, TextSchema, true)
232
+ ensureContainers(doc2, TextSchema)
233
233
  const substrate2 = createYjsSubstrate(doc2, TextSchema)
234
234
  const ref2 = createRef(TextSchema, substrate2) as any
235
235
 
@@ -285,7 +285,7 @@ describe("YjsPosition: concurrent edits", () => {
285
285
  // Decode on a different doc that has the same state
286
286
  const doc2 = new Y.Doc()
287
287
  Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
288
- ensureContainers(doc2, TextSchema, true)
288
+ ensureContainers(doc2, TextSchema)
289
289
  const substrate2 = createYjsSubstrate(doc2, TextSchema)
290
290
  const ref2 = createRef(TextSchema, substrate2) as any
291
291
 
@@ -310,13 +310,13 @@ describe("YjsPosition: concurrent edits", () => {
310
310
 
311
311
  const doc2 = new Y.Doc()
312
312
  Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
313
- ensureContainers(doc2, TextSchema, true)
313
+ ensureContainers(doc2, TextSchema)
314
314
  const substrate2 = createYjsSubstrate(doc2, TextSchema)
315
315
  const ref2 = createRef(TextSchema, substrate2) as any
316
316
 
317
317
  const doc3 = new Y.Doc()
318
318
  Y.applyUpdate(doc3, Y.encodeStateAsUpdate(doc1))
319
- ensureContainers(doc3, TextSchema, true)
319
+ ensureContainers(doc3, TextSchema)
320
320
  const substrate3 = createYjsSubstrate(doc3, TextSchema)
321
321
  const ref3 = createRef(TextSchema, substrate3) as any
322
322
 
@@ -147,7 +147,7 @@ describe("record-of-struct (plain baseline)", () => {
147
147
 
148
148
  const delta = exportSince(docA, v0)
149
149
  expect(delta).not.toBeNull()
150
- merge(docB, delta!, "sync")
150
+ merge(docB, delta!, { origin: "sync" })
151
151
 
152
152
  expect(docB.profiles()).toEqual({
153
153
  alice: { displayName: "Alice", age: 30 },
@@ -304,7 +304,7 @@ describe("text-inside-struct-inside-record", () => {
304
304
 
305
305
  const delta = exportSince(docA, v0)
306
306
  expect(delta).not.toBeNull()
307
- merge(docB, delta!, "sync")
307
+ merge(docB, delta!, { origin: "sync" })
308
308
 
309
309
  expect(docB.profiles()).toEqual({
310
310
  alice: { displayName: "Alice", bio: "Hello from A" },
@@ -342,8 +342,8 @@ describe("text-inside-struct-inside-record", () => {
342
342
  const deltaBA = exportSince(docB, vA)
343
343
  expect(deltaAB).not.toBeNull()
344
344
  expect(deltaBA).not.toBeNull()
345
- merge(docB, deltaAB!, "sync")
346
- merge(docA, deltaBA!, "sync")
345
+ merge(docB, deltaAB!, { origin: "sync" })
346
+ merge(docA, deltaBA!, { origin: "sync" })
347
347
 
348
348
  // Both converge to the same value (order depends on client IDs)
349
349
  expect((docA as any).profiles.at("alice").bio()).toBe(
@@ -429,7 +429,7 @@ describe("text-inside-struct-inside-list", () => {
429
429
 
430
430
  const delta = exportSince(docA, v0)
431
431
  expect(delta).not.toBeNull()
432
- merge(docB, delta!, "sync")
432
+ merge(docB, delta!, { origin: "sync" })
433
433
 
434
434
  expect(docB.players.length).toBe(1)
435
435
  expect((docB as any).players.at(0).name()).toBe("Alice")
@@ -60,7 +60,7 @@ describe("structural merge protocol (Yjs)", () => {
60
60
  // Peer A creates doc and writes text
61
61
  const docA = new Y.Doc()
62
62
  docA.clientID = 100
63
- ensureContainers(docA, TestSchema, false, binding)
63
+ ensureContainers(docA, TestSchema, binding)
64
64
  docA.transact(() => {
65
65
  const root = docA.getMap("root")
66
66
  ;(root.get(id("title")) as Y.Text).insert(0, "Hello from A")
@@ -70,7 +70,7 @@ describe("structural merge protocol (Yjs)", () => {
70
70
  // Peer B independently creates same doc and writes different text
71
71
  const docB = new Y.Doc()
72
72
  docB.clientID = 200
73
- ensureContainers(docB, TestSchema, false, binding)
73
+ ensureContainers(docB, TestSchema, binding)
74
74
  docB.transact(() => {
75
75
  const root = docB.getMap("root")
76
76
  ;(root.get(id("title")) as Y.Text).insert(0, "Hello from B")
@@ -105,7 +105,7 @@ describe("structural merge protocol (Yjs)", () => {
105
105
  const docs = [100, 200, 300].map(cid => {
106
106
  const doc = new Y.Doc()
107
107
  doc.clientID = cid
108
- ensureContainers(doc, TestSchema, false, binding)
108
+ ensureContainers(doc, TestSchema, binding)
109
109
  doc.transact(() => {
110
110
  const root = doc.getMap("root")
111
111
  ;(root.get(id("title")) as Y.Text).insert(0, `Peer${cid}`)
@@ -145,7 +145,7 @@ describe("structural merge protocol (Yjs)", () => {
145
145
  // Create and write
146
146
  const doc1 = new Y.Doc()
147
147
  doc1.clientID = 42
148
- ensureContainers(doc1, TestSchema, false, binding)
148
+ ensureContainers(doc1, TestSchema, binding)
149
149
  doc1.transact(() => {
150
150
  const root = doc1.getMap("root")
151
151
  ;(root.get(id("title")) as Y.Text).insert(0, "Persistent")
@@ -159,7 +159,7 @@ describe("structural merge protocol (Yjs)", () => {
159
159
  const doc2 = new Y.Doc()
160
160
  doc2.clientID = 42
161
161
  Y.applyUpdate(doc2, snapshot)
162
- ensureContainers(doc2, TestSchema, true, binding) // conditional
162
+ ensureContainers(doc2, TestSchema, binding)
163
163
 
164
164
  // Data preserved
165
165
  const root2 = doc2.getMap("root")
@@ -190,10 +190,10 @@ describe("structural merge protocol (Yjs)", () => {
190
190
  const bindingB = trivialBinding(schemaB)
191
191
 
192
192
  const docA = new Y.Doc()
193
- ensureContainers(docA, schemaA, false, bindingA)
193
+ ensureContainers(docA, schemaA, bindingA)
194
194
 
195
195
  const docB = new Y.Doc()
196
- ensureContainers(docB, schemaB, false, bindingB)
196
+ ensureContainers(docB, schemaB, bindingB)
197
197
 
198
198
  // Both should produce byte-identical structural state
199
199
  const stateA = Y.encodeStateAsUpdate(docA)
@@ -221,7 +221,7 @@ describe("structural merge protocol (Yjs)", () => {
221
221
  // Peer A: create v1, write data, export
222
222
  const docA = new Y.Doc()
223
223
  docA.clientID = 100
224
- ensureContainers(docA, v1Schema, false, v1Binding)
224
+ ensureContainers(docA, v1Schema, v1Binding)
225
225
  docA.transact(() => {
226
226
  const root = docA.getMap("root")
227
227
  ;(root.get(id("title")) as Y.Text).insert(0, "Title")
@@ -229,17 +229,17 @@ describe("structural merge protocol (Yjs)", () => {
229
229
  })
230
230
  const v1State = Y.encodeStateAsUpdate(docA)
231
231
 
232
- // Peer B: independently create v2, hydrate v1 data, conditional containers
232
+ // Peer B: independently create v2, hydrate v1 data, ensure containers
233
233
  const docB = new Y.Doc()
234
234
  docB.clientID = 200
235
235
  Y.applyUpdate(docB, v1State)
236
- ensureContainers(docB, v2Schema, true, v2Binding) // conditional — only creates "notes"
236
+ ensureContainers(docB, v2Schema, v2Binding)
237
237
 
238
238
  // Another peer C: same thing independently
239
239
  const docC = new Y.Doc()
240
240
  docC.clientID = 300
241
241
  Y.applyUpdate(docC, v1State)
242
- ensureContainers(docC, v2Schema, true, v2Binding)
242
+ ensureContainers(docC, v2Schema, v2Binding)
243
243
 
244
244
  // B and C's structural ops for "notes" should be identical (both at clientID 0)
245
245
  const stateB = Y.encodeStateAsUpdate(docB)
@@ -270,7 +270,7 @@ describe("structural merge protocol (Yjs)", () => {
270
270
 
271
271
  const doc = new Y.Doc()
272
272
  doc.clientID = 999
273
- ensureContainers(doc, TestSchema, false, binding)
273
+ ensureContainers(doc, TestSchema, binding)
274
274
 
275
275
  // clientID should be restored
276
276
  expect(doc.clientID).toBe(999)
@@ -285,7 +285,7 @@ describe("structural merge protocol (Yjs)", () => {
285
285
 
286
286
  const doc = new Y.Doc()
287
287
  doc.clientID = 777
288
- ensureContainers(doc, TestSchema, false, binding)
288
+ ensureContainers(doc, TestSchema, binding)
289
289
 
290
290
  // The state vector should NOT contain the caller's clientID —
291
291
  // only STRUCTURAL_YJS_CLIENT_ID (0) should have produced ops.
@@ -383,8 +383,8 @@ describe("structural merge protocol (Yjs)", () => {
383
383
  // Bidirectional merge — should not throw
384
384
  const payloadA = subA.exportEntirety()
385
385
  const payloadB = subB.exportEntirety()
386
- subA.merge(payloadB, "sync")
387
- subB.merge(payloadA, "sync")
386
+ subA.merge(payloadB, { origin: "sync" })
387
+ subB.merge(payloadA, { origin: "sync" })
388
388
 
389
389
  // Both converge
390
390
  const rootA = docA.getMap("root")
@@ -399,19 +399,19 @@ describe("structural merge protocol (Yjs)", () => {
399
399
  expect(rootA.get(id("count"))).toBe(rootB.get(id("count")))
400
400
  })
401
401
 
402
- // ── Conditional ensureContainers is idempotent ──
402
+ // ── ensureContainers is idempotent ──
403
403
 
404
- it("conditional ensureContainers is idempotent on hydrated doc", () => {
404
+ it("ensureContainers after hydration preserves existing data", () => {
405
405
  const binding = trivialBinding(TestSchema)
406
406
 
407
407
  const doc = new Y.Doc()
408
408
  doc.clientID = 50
409
- ensureContainers(doc, TestSchema, false, binding)
409
+ ensureContainers(doc, TestSchema, binding)
410
410
 
411
411
  const stateBefore = Y.encodeStateAsUpdate(doc)
412
412
 
413
- // Conditional call should not create new ops (everything already exists)
414
- ensureContainers(doc, TestSchema, true, binding)
413
+ // Repeated call should not create new ops (everything already exists)
414
+ ensureContainers(doc, TestSchema, binding)
415
415
 
416
416
  const stateAfter = Y.encodeStateAsUpdate(doc)
417
417
  expect(stateAfter).toEqual(stateBefore)
@@ -607,4 +607,49 @@ describe("YjsSubstrate", () => {
607
607
  expect(parsed.compare(v)).toBe("equal")
608
608
  })
609
609
  })
610
+
611
+ // -------------------------------------------------------------------------
612
+ // Re-entrant write during merge replay
613
+ // -------------------------------------------------------------------------
614
+ //
615
+ // A subscriber that calls `change(doc, ...)` while delivering a sync
616
+ // merge must reach Yjs — otherwise the substrate stalls and the
617
+ // subscriber loops on stale state until the lease budget trips.
618
+ // Context: jj:qpultxsw.
619
+
620
+ describe("re-entrant write during merge replay", () => {
621
+ it("subscriber's local change() inside a merge-replay batch lands in Yjs", () => {
622
+ const docA = createDoc(yjs.bind(SimpleSchema))
623
+ const docB = createDoc(yjs.bind(SimpleSchema))
624
+
625
+ change(docA, (d: any) => {
626
+ d.title.insert(0, "seed")
627
+ })
628
+ merge(docB, exportEntirety(docA), { origin: "sync" })
629
+
630
+ // On the first replay-driven update, the subscriber writes once
631
+ // to an unrelated field. The write must hit Yjs; the guard
632
+ // ensures we don't re-enter on subsequent flushes.
633
+ let writes = 0
634
+ subscribe(docB.title, () => {
635
+ if (writes === 0 && (docB.title() as string) === "seedmore") {
636
+ writes++
637
+ change(docB, (d: any) => {
638
+ d.count.set(42)
639
+ })
640
+ }
641
+ })
642
+
643
+ const v0 = version(docB)
644
+ change(docA, (d: any) => {
645
+ d.title.insert((d.title() as string).length, "more")
646
+ })
647
+ const delta = exportSince(docA, v0)!
648
+ merge(docB, delta, { origin: "sync" })
649
+
650
+ expect(docB.title()).toBe("seedmore")
651
+ expect(docB.count()).toBe(42)
652
+ expect(writes).toBe(1)
653
+ })
654
+ })
610
655
  })
package/src/bind-yjs.ts CHANGED
@@ -105,17 +105,15 @@ function createYjsFactory(
105
105
  // Set stable identity AFTER hydration — avoids Yjs clientID
106
106
  // conflict detection that would reassign to a random value.
107
107
  doc.clientID = numericClientId
108
- // Conditional ensureContainers: skip fields that already exist
109
- // from hydrated state (each set() is a CRDT write).
110
- ensureContainers(doc, schema, true, binding)
108
+ ensureContainers(doc, schema, binding)
111
109
  return createYjsSubstrate(doc, schema, binding)
112
110
  },
113
111
 
114
112
  create(schema: SchemaNode): Substrate<YjsVersion> {
115
- // Fresh doc — set identity immediately, unconditional containers.
113
+ // Fresh doc — set identity immediately.
116
114
  const doc = new Y.Doc()
117
115
  doc.clientID = numericClientId
118
- ensureContainers(doc, schema, false, binding)
116
+ ensureContainers(doc, schema, binding)
119
117
  return createYjsSubstrate(doc, schema, binding)
120
118
  },
121
119