@agent-native/core 0.45.0 → 0.46.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 (157) hide show
  1. package/README.md +1 -0
  2. package/dist/action.d.ts +8 -1
  3. package/dist/action.d.ts.map +1 -1
  4. package/dist/action.js +20 -10
  5. package/dist/action.js.map +1 -1
  6. package/dist/cli/app-skill.d.ts +3 -1
  7. package/dist/cli/app-skill.d.ts.map +1 -1
  8. package/dist/cli/app-skill.js +50 -8
  9. package/dist/cli/app-skill.js.map +1 -1
  10. package/dist/cli/connect.d.ts.map +1 -1
  11. package/dist/cli/connect.js +39 -5
  12. package/dist/cli/connect.js.map +1 -1
  13. package/dist/cli/create.d.ts.map +1 -1
  14. package/dist/cli/create.js +9 -7
  15. package/dist/cli/create.js.map +1 -1
  16. package/dist/cli/index.js +42 -10
  17. package/dist/cli/index.js.map +1 -1
  18. package/dist/cli/mcp-config-writers.d.ts +10 -0
  19. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  20. package/dist/cli/mcp-config-writers.js +60 -6
  21. package/dist/cli/mcp-config-writers.js.map +1 -1
  22. package/dist/cli/mcp.d.ts.map +1 -1
  23. package/dist/cli/mcp.js +4 -6
  24. package/dist/cli/mcp.js.map +1 -1
  25. package/dist/cli/plan-local.d.ts.map +1 -1
  26. package/dist/cli/plan-local.js +15 -2
  27. package/dist/cli/plan-local.js.map +1 -1
  28. package/dist/cli/plan-publish-store.d.ts +17 -7
  29. package/dist/cli/plan-publish-store.d.ts.map +1 -1
  30. package/dist/cli/plan-publish-store.js +33 -8
  31. package/dist/cli/plan-publish-store.js.map +1 -1
  32. package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
  33. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  34. package/dist/cli/pr-visual-recap-workflow.js +1 -1
  35. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  36. package/dist/cli/recap.d.ts +63 -5
  37. package/dist/cli/recap.d.ts.map +1 -1
  38. package/dist/cli/recap.js +641 -48
  39. package/dist/cli/recap.js.map +1 -1
  40. package/dist/cli/skills.d.ts +26 -11
  41. package/dist/cli/skills.d.ts.map +1 -1
  42. package/dist/cli/skills.js +644 -972
  43. package/dist/cli/skills.js.map +1 -1
  44. package/dist/cli/templates-meta.d.ts.map +1 -1
  45. package/dist/cli/templates-meta.js +3 -2
  46. package/dist/cli/templates-meta.js.map +1 -1
  47. package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
  48. package/dist/client/blocks/library/AnnotatedCodeBlock.js +37 -9
  49. package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
  50. package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
  51. package/dist/client/blocks/library/DiffBlock.js +44 -12
  52. package/dist/client/blocks/library/DiffBlock.js.map +1 -1
  53. package/dist/client/blocks/library/annotation-rail.d.ts +12 -3
  54. package/dist/client/blocks/library/annotation-rail.d.ts.map +1 -1
  55. package/dist/client/blocks/library/annotation-rail.js +29 -3
  56. package/dist/client/blocks/library/annotation-rail.js.map +1 -1
  57. package/dist/client/blocks/library/html.d.ts.map +1 -1
  58. package/dist/client/blocks/library/html.js +3 -1
  59. package/dist/client/blocks/library/html.js.map +1 -1
  60. package/dist/client/blocks/library/question-form.d.ts.map +1 -1
  61. package/dist/client/blocks/library/question-form.js +4 -1
  62. package/dist/client/blocks/library/question-form.js.map +1 -1
  63. package/dist/client/components/LiveCursorOverlay.d.ts +46 -0
  64. package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -0
  65. package/dist/client/components/LiveCursorOverlay.js +137 -0
  66. package/dist/client/components/LiveCursorOverlay.js.map +1 -0
  67. package/dist/client/components/PresenceBar.d.ts +11 -1
  68. package/dist/client/components/PresenceBar.d.ts.map +1 -1
  69. package/dist/client/components/PresenceBar.js +39 -7
  70. package/dist/client/components/PresenceBar.js.map +1 -1
  71. package/dist/client/components/RemoteSelectionRings.d.ts +43 -0
  72. package/dist/client/components/RemoteSelectionRings.d.ts.map +1 -0
  73. package/dist/client/components/RemoteSelectionRings.js +116 -0
  74. package/dist/client/components/RemoteSelectionRings.js.map +1 -0
  75. package/dist/client/index.d.ts +4 -0
  76. package/dist/client/index.d.ts.map +1 -1
  77. package/dist/client/index.js +5 -0
  78. package/dist/client/index.js.map +1 -1
  79. package/dist/collab/awareness.d.ts +25 -0
  80. package/dist/collab/awareness.d.ts.map +1 -1
  81. package/dist/collab/awareness.js +42 -5
  82. package/dist/collab/awareness.js.map +1 -1
  83. package/dist/collab/client.d.ts +19 -1
  84. package/dist/collab/client.d.ts.map +1 -1
  85. package/dist/collab/client.js +362 -57
  86. package/dist/collab/client.js.map +1 -1
  87. package/dist/collab/follow-mode.d.ts +56 -0
  88. package/dist/collab/follow-mode.d.ts.map +1 -0
  89. package/dist/collab/follow-mode.js +54 -0
  90. package/dist/collab/follow-mode.js.map +1 -0
  91. package/dist/collab/index.d.ts +3 -1
  92. package/dist/collab/index.d.ts.map +1 -1
  93. package/dist/collab/index.js +5 -1
  94. package/dist/collab/index.js.map +1 -1
  95. package/dist/collab/presence.d.ts +56 -0
  96. package/dist/collab/presence.d.ts.map +1 -0
  97. package/dist/collab/presence.js +98 -0
  98. package/dist/collab/presence.js.map +1 -0
  99. package/dist/collab/routes.d.ts.map +1 -1
  100. package/dist/collab/routes.js +33 -6
  101. package/dist/collab/routes.js.map +1 -1
  102. package/dist/collab/struct-routes.d.ts.map +1 -1
  103. package/dist/collab/struct-routes.js +24 -4
  104. package/dist/collab/struct-routes.js.map +1 -1
  105. package/dist/collab/ydoc-manager.d.ts +13 -0
  106. package/dist/collab/ydoc-manager.d.ts.map +1 -1
  107. package/dist/collab/ydoc-manager.js +51 -15
  108. package/dist/collab/ydoc-manager.js.map +1 -1
  109. package/dist/db/migrations.d.ts.map +1 -1
  110. package/dist/db/migrations.js +2 -1
  111. package/dist/db/migrations.js.map +1 -1
  112. package/dist/extensions/routes.d.ts +18 -0
  113. package/dist/extensions/routes.d.ts.map +1 -1
  114. package/dist/extensions/routes.js +30 -8
  115. package/dist/extensions/routes.js.map +1 -1
  116. package/dist/oauth-tokens/store.d.ts.map +1 -1
  117. package/dist/oauth-tokens/store.js +42 -5
  118. package/dist/oauth-tokens/store.js.map +1 -1
  119. package/dist/scripts/db/index.d.ts.map +1 -1
  120. package/dist/scripts/db/index.js +1 -0
  121. package/dist/scripts/db/index.js.map +1 -1
  122. package/dist/scripts/db/migrate-encrypt-oauth-tokens.d.ts +28 -0
  123. package/dist/scripts/db/migrate-encrypt-oauth-tokens.d.ts.map +1 -0
  124. package/dist/scripts/db/migrate-encrypt-oauth-tokens.js +164 -0
  125. package/dist/scripts/db/migrate-encrypt-oauth-tokens.js.map +1 -0
  126. package/dist/scripts/db/scoping.d.ts.map +1 -1
  127. package/dist/scripts/db/scoping.js +7 -5
  128. package/dist/scripts/db/scoping.js.map +1 -1
  129. package/dist/secrets/index.d.ts +1 -0
  130. package/dist/secrets/index.d.ts.map +1 -1
  131. package/dist/secrets/index.js +4 -0
  132. package/dist/secrets/index.js.map +1 -1
  133. package/dist/server/collab-plugin.d.ts +6 -0
  134. package/dist/server/collab-plugin.d.ts.map +1 -1
  135. package/dist/server/collab-plugin.js +105 -5
  136. package/dist/server/collab-plugin.js.map +1 -1
  137. package/dist/server/poll-events.d.ts +5 -0
  138. package/dist/server/poll-events.d.ts.map +1 -1
  139. package/dist/server/poll-events.js +27 -4
  140. package/dist/server/poll-events.js.map +1 -1
  141. package/dist/sharing/actions/set-resource-visibility.d.ts.map +1 -1
  142. package/dist/sharing/actions/set-resource-visibility.js +4 -1
  143. package/dist/sharing/actions/set-resource-visibility.js.map +1 -1
  144. package/dist/templates/default/.agents/skills/real-time-collab/SKILL.md +185 -37
  145. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
  146. package/dist/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
  147. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
  148. package/docs/content/plan-plugin.md +21 -6
  149. package/docs/content/pr-visual-recap.md +52 -3
  150. package/docs/content/real-time-collaboration.md +481 -97
  151. package/docs/content/skills-guide.md +13 -0
  152. package/docs/content/template-plan.md +18 -7
  153. package/package.json +5 -1
  154. package/src/templates/default/.agents/skills/real-time-collab/SKILL.md +185 -37
  155. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
  156. package/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
  157. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
@@ -8,6 +8,24 @@ import * as manager from "./ydoc-manager.js";
8
8
  import { searchAndReplace as doSearchAndReplace } from "./ydoc-manager.js";
9
9
  import { uint8ArrayToBase64, base64ToUint8Array } from "./storage.js";
10
10
  import { readBody } from "../server/h3-helpers.js";
11
+ /** Default maximum payload size (2 MB). Overridden by plugin via event.context. */
12
+ const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
13
+ function getMaxPayloadBytes(event) {
14
+ return event.context?._collabMaxPayloadBytes ?? DEFAULT_MAX_BYTES;
15
+ }
16
+ /**
17
+ * Check the serialized body length against the configured limit.
18
+ * Returns true if within limits; sets 413 status and returns false otherwise.
19
+ */
20
+ function enforcePayloadLimit(event, body) {
21
+ const maxBytes = getMaxPayloadBytes(event);
22
+ const encoded = typeof body === "string" ? body : JSON.stringify(body ?? "");
23
+ if (encoded.length > maxBytes) {
24
+ setResponseStatus(event, 413);
25
+ return false;
26
+ }
27
+ return true;
28
+ }
11
29
  /**
12
30
  * GET /_agent-native/collab/:docId/state
13
31
  *
@@ -53,8 +71,11 @@ export const postCollabUpdate = defineEventHandler(async (event) => {
53
71
  setResponseStatus(event, 400);
54
72
  return { error: "docId required" };
55
73
  }
56
- const body = await readBody(event);
57
- const { update, requestSource } = body;
74
+ const rawBody = await readBody(event);
75
+ if (!enforcePayloadLimit(event, rawBody)) {
76
+ return { error: "Payload too large" };
77
+ }
78
+ const { update, requestSource } = rawBody;
58
79
  if (!update) {
59
80
  setResponseStatus(event, 400);
60
81
  return { error: "update (base64) required" };
@@ -77,8 +98,11 @@ export const postCollabText = defineEventHandler(async (event) => {
77
98
  setResponseStatus(event, 400);
78
99
  return { error: "docId required" };
79
100
  }
80
- const body = await readBody(event);
81
- const { text, fieldName, requestSource } = body;
101
+ const rawBody = await readBody(event);
102
+ if (!enforcePayloadLimit(event, rawBody)) {
103
+ return { error: "Payload too large" };
104
+ }
105
+ const { text, fieldName, requestSource } = rawBody;
82
106
  if (text === undefined) {
83
107
  setResponseStatus(event, 400);
84
108
  return { error: "text required" };
@@ -100,8 +124,11 @@ export const postCollabSearchReplace = defineEventHandler(async (event) => {
100
124
  setResponseStatus(event, 400);
101
125
  return { error: "docId required" };
102
126
  }
103
- const body = await readBody(event);
104
- const { find, replace, requestSource } = body;
127
+ const rawBody = await readBody(event);
128
+ if (!enforcePayloadLimit(event, rawBody)) {
129
+ return { error: "Payload too large" };
130
+ }
131
+ const { find, replace, requestSource } = rawBody;
105
132
  if (!find) {
106
133
  setResponseStatus(event, 400);
107
134
  return { error: "find required" };
@@ -1 +1 @@
1
- {"version":3,"file":"routes.js","sourceRoot":"","sources":["../../src/collab/routes.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * HTTP route handlers for collaborative editing.\n *\n * Mounted under /_agent-native/collab/ by the collab plugin.\n */\n\nimport {\n defineEventHandler,\n setResponseStatus,\n getRouterParam,\n getQuery,\n} from \"h3\";\nimport type { H3Event } from \"h3\";\nimport * as manager from \"./ydoc-manager.js\";\nimport { searchAndReplace as doSearchAndReplace } from \"./ydoc-manager.js\";\nimport { uint8ArrayToBase64, base64ToUint8Array } from \"./storage.js\";\nimport { readBody } from \"../server/h3-helpers.js\";\n\n/**\n * GET /_agent-native/collab/:docId/state\n *\n * Returns full Yjs document state as base64 for initial client load.\n */\nexport const getCollabState = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const query = getQuery(event);\n const encodedStateVector =\n typeof query.stateVector === \"string\" ? query.stateVector : null;\n let state: Uint8Array;\n if (encodedStateVector) {\n try {\n state = await manager.getIncUpdate(\n docId,\n base64ToUint8Array(encodedStateVector),\n );\n } catch {\n setResponseStatus(event, 400);\n return { error: \"stateVector must be base64-encoded\" };\n }\n } else {\n state = await manager.getState(docId);\n }\n return {\n docId,\n state: uint8ArrayToBase64(state),\n };\n});\n\n/**\n * POST /_agent-native/collab/:docId/update\n *\n * Client sends a Yjs update (base64). Server applies it, persists, and\n * emits a change event so other clients pick it up via polling.\n *\n * Body: { update: string (base64), requestSource?: string }\n */\nexport const postCollabUpdate = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const body = await readBody(event);\n const { update, requestSource } = body as {\n update?: string;\n requestSource?: string;\n };\n\n if (!update) {\n setResponseStatus(event, 400);\n return { error: \"update (base64) required\" };\n }\n\n const binary = base64ToUint8Array(update);\n await manager.applyUpdate(docId, binary, requestSource);\n\n return { ok: true };\n});\n\n/**\n * POST /_agent-native/collab/:docId/text\n *\n * Agent sends full text content. Server computes diff against current\n * Yjs state and applies minimal operations.\n *\n * Body: { text: string, fieldName?: string, requestSource?: string }\n */\nexport const postCollabText = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const body = await readBody(event);\n const { text, fieldName, requestSource } = body as {\n text?: string;\n fieldName?: string;\n requestSource?: string;\n };\n\n if (text === undefined) {\n setResponseStatus(event, 400);\n return { error: \"text required\" };\n }\n\n const result = await manager.applyText(\n docId,\n text,\n fieldName ?? \"content\",\n requestSource ?? \"agent\",\n );\n\n return { ok: true, text: result };\n});\n\n/**\n * POST /_agent-native/collab/:docId/search-replace\n *\n * Search-and-replace text in the Y.XmlFragment (ProseMirror tree).\n * Produces minimal Yjs operations for cursor-preserving updates.\n *\n * Body: { find: string, replace: string, requestSource?: string }\n */\nexport const postCollabSearchReplace = defineEventHandler(\n async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const body = await readBody(event);\n const { find, replace, requestSource } = body as {\n find?: string;\n replace?: string;\n requestSource?: string;\n };\n\n if (!find) {\n setResponseStatus(event, 400);\n return { error: \"find required\" };\n }\n\n const result = await doSearchAndReplace(\n docId,\n find,\n replace ?? \"\",\n requestSource ?? \"agent\",\n );\n\n return { ok: true, found: result.found };\n },\n);\n"]}
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../../src/collab/routes.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * HTTP route handlers for collaborative editing.\n *\n * Mounted under /_agent-native/collab/ by the collab plugin.\n */\n\nimport {\n defineEventHandler,\n setResponseStatus,\n getRouterParam,\n getQuery,\n} from \"h3\";\nimport type { H3Event } from \"h3\";\nimport * as manager from \"./ydoc-manager.js\";\nimport { searchAndReplace as doSearchAndReplace } from \"./ydoc-manager.js\";\nimport { uint8ArrayToBase64, base64ToUint8Array } from \"./storage.js\";\nimport { readBody } from \"../server/h3-helpers.js\";\n\n/** Default maximum payload size (2 MB). Overridden by plugin via event.context. */\nconst DEFAULT_MAX_BYTES = 2 * 1024 * 1024;\n\nfunction getMaxPayloadBytes(event: H3Event): number {\n return (event.context as any)?._collabMaxPayloadBytes ?? DEFAULT_MAX_BYTES;\n}\n\n/**\n * Check the serialized body length against the configured limit.\n * Returns true if within limits; sets 413 status and returns false otherwise.\n */\nfunction enforcePayloadLimit(event: H3Event, body: unknown): boolean {\n const maxBytes = getMaxPayloadBytes(event);\n const encoded = typeof body === \"string\" ? body : JSON.stringify(body ?? \"\");\n if (encoded.length > maxBytes) {\n setResponseStatus(event, 413);\n return false;\n }\n return true;\n}\n\n/**\n * GET /_agent-native/collab/:docId/state\n *\n * Returns full Yjs document state as base64 for initial client load.\n */\nexport const getCollabState = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const query = getQuery(event);\n const encodedStateVector =\n typeof query.stateVector === \"string\" ? query.stateVector : null;\n let state: Uint8Array;\n if (encodedStateVector) {\n try {\n state = await manager.getIncUpdate(\n docId,\n base64ToUint8Array(encodedStateVector),\n );\n } catch {\n setResponseStatus(event, 400);\n return { error: \"stateVector must be base64-encoded\" };\n }\n } else {\n state = await manager.getState(docId);\n }\n return {\n docId,\n state: uint8ArrayToBase64(state),\n };\n});\n\n/**\n * POST /_agent-native/collab/:docId/update\n *\n * Client sends a Yjs update (base64). Server applies it, persists, and\n * emits a change event so other clients pick it up via polling.\n *\n * Body: { update: string (base64), requestSource?: string }\n */\nexport const postCollabUpdate = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const rawBody = await readBody(event);\n if (!enforcePayloadLimit(event, rawBody)) {\n return { error: \"Payload too large\" };\n }\n const { update, requestSource } = rawBody as {\n update?: string;\n requestSource?: string;\n };\n\n if (!update) {\n setResponseStatus(event, 400);\n return { error: \"update (base64) required\" };\n }\n\n const binary = base64ToUint8Array(update);\n await manager.applyUpdate(docId, binary, requestSource);\n\n return { ok: true };\n});\n\n/**\n * POST /_agent-native/collab/:docId/text\n *\n * Agent sends full text content. Server computes diff against current\n * Yjs state and applies minimal operations.\n *\n * Body: { text: string, fieldName?: string, requestSource?: string }\n */\nexport const postCollabText = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const rawBody = await readBody(event);\n if (!enforcePayloadLimit(event, rawBody)) {\n return { error: \"Payload too large\" };\n }\n const { text, fieldName, requestSource } = rawBody as {\n text?: string;\n fieldName?: string;\n requestSource?: string;\n };\n\n if (text === undefined) {\n setResponseStatus(event, 400);\n return { error: \"text required\" };\n }\n\n const result = await manager.applyText(\n docId,\n text,\n fieldName ?? \"content\",\n requestSource ?? \"agent\",\n );\n\n return { ok: true, text: result };\n});\n\n/**\n * POST /_agent-native/collab/:docId/search-replace\n *\n * Search-and-replace text in the Y.XmlFragment (ProseMirror tree).\n * Produces minimal Yjs operations for cursor-preserving updates.\n *\n * Body: { find: string, replace: string, requestSource?: string }\n */\nexport const postCollabSearchReplace = defineEventHandler(\n async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const rawBody = await readBody(event);\n if (!enforcePayloadLimit(event, rawBody)) {\n return { error: \"Payload too large\" };\n }\n const { find, replace, requestSource } = rawBody as {\n find?: string;\n replace?: string;\n requestSource?: string;\n };\n\n if (!find) {\n setResponseStatus(event, 400);\n return { error: \"find required\" };\n }\n\n const result = await doSearchAndReplace(\n docId,\n find,\n replace ?? \"\",\n requestSource ?? \"agent\",\n );\n\n return { ok: true, found: result.found };\n },\n);\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"struct-routes.d.ts","sourceRoot":"","sources":["../../src/collab/struct-routes.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc;;;;;;GA6BzB,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,eAAe;;;;;;GA2B1B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,aAAa;;;;;;;;GAYxB,CAAC"}
1
+ {"version":3,"file":"struct-routes.d.ts","sourceRoot":"","sources":["../../src/collab/struct-routes.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AA0BH;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc;;;;;;GAgCzB,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,eAAe;;;;;;GA8B1B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,aAAa;;;;;;;;GAYxB,CAAC"}
@@ -8,6 +8,20 @@ import { defineEventHandler, setResponseStatus, getRouterParam } from "h3";
8
8
  import { getQuery } from "h3";
9
9
  import * as manager from "./ydoc-manager.js";
10
10
  import { readBody } from "../server/h3-helpers.js";
11
+ /** Default maximum payload size (2 MB). Overridden by plugin via event.context. */
12
+ const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
13
+ function getMaxPayloadBytes(event) {
14
+ return event.context?._collabMaxPayloadBytes ?? DEFAULT_MAX_BYTES;
15
+ }
16
+ function enforcePayloadLimit(event, body) {
17
+ const maxBytes = getMaxPayloadBytes(event);
18
+ const encoded = typeof body === "string" ? body : JSON.stringify(body ?? "");
19
+ if (encoded.length > maxBytes) {
20
+ setResponseStatus(event, 413);
21
+ return false;
22
+ }
23
+ return true;
24
+ }
11
25
  /**
12
26
  * POST /_agent-native/collab/:docId/json
13
27
  *
@@ -22,8 +36,11 @@ export const postCollabJson = defineEventHandler(async (event) => {
22
36
  setResponseStatus(event, 400);
23
37
  return { error: "docId required" };
24
38
  }
25
- const body = await readBody(event);
26
- const { json, fieldName, type, requestSource } = body;
39
+ const rawBody = await readBody(event);
40
+ if (!enforcePayloadLimit(event, rawBody)) {
41
+ return { error: "Payload too large" };
42
+ }
43
+ const { json, fieldName, type, requestSource } = rawBody;
27
44
  if (json === undefined) {
28
45
  setResponseStatus(event, 400);
29
46
  return { error: "json required" };
@@ -44,8 +61,11 @@ export const postCollabPatch = defineEventHandler(async (event) => {
44
61
  setResponseStatus(event, 400);
45
62
  return { error: "docId required" };
46
63
  }
47
- const body = await readBody(event);
48
- const { ops, fieldName, requestSource } = body;
64
+ const rawBody = await readBody(event);
65
+ if (!enforcePayloadLimit(event, rawBody)) {
66
+ return { error: "Payload too large" };
67
+ }
68
+ const { ops, fieldName, requestSource } = rawBody;
49
69
  if (!ops || !Array.isArray(ops)) {
50
70
  setResponseStatus(event, 400);
51
71
  return { error: "ops (array) required" };
@@ -1 +1 @@
1
- {"version":3,"file":"struct-routes.js","sourceRoot":"","sources":["../../src/collab/struct-routes.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * HTTP route handlers for structured (JSON) collaborative editing.\n *\n * Mounted under /_agent-native/collab/ by the collab plugin alongside\n * the text-based routes in routes.ts.\n */\n\nimport { defineEventHandler, setResponseStatus, getRouterParam } from \"h3\";\nimport type { H3Event } from \"h3\";\nimport { getQuery } from \"h3\";\nimport * as manager from \"./ydoc-manager.js\";\nimport { readBody } from \"../server/h3-helpers.js\";\nimport type { PatchOp } from \"./json-to-yjs.js\";\n\n/**\n * POST /_agent-native/collab/:docId/json\n *\n * Apply full JSON content to a collaborative document. The server diffs\n * against the current Yjs state and applies minimal operations.\n *\n * Body: { json: any, fieldName?: string, type?: \"map\"|\"array\", requestSource?: string }\n */\nexport const postCollabJson = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const body = await readBody(event);\n const { json, fieldName, type, requestSource } = body as {\n json?: any;\n fieldName?: string;\n type?: \"map\" | \"array\";\n requestSource?: string;\n };\n\n if (json === undefined) {\n setResponseStatus(event, 400);\n return { error: \"json required\" };\n }\n\n await manager.applyJson(\n docId,\n json,\n fieldName ?? \"data\",\n type ?? (Array.isArray(json) ? \"array\" : \"map\"),\n requestSource ?? \"agent\",\n );\n\n return { ok: true };\n});\n\n/**\n * POST /_agent-native/collab/:docId/patch\n *\n * Apply surgical JSON patch operations to a collaborative document.\n *\n * Body: { ops: PatchOp[], fieldName?: string, requestSource?: string }\n */\nexport const postCollabPatch = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const body = await readBody(event);\n const { ops, fieldName, requestSource } = body as {\n ops?: PatchOp[];\n fieldName?: string;\n requestSource?: string;\n };\n\n if (!ops || !Array.isArray(ops)) {\n setResponseStatus(event, 400);\n return { error: \"ops (array) required\" };\n }\n\n await manager.applyPatchOps(\n docId,\n ops,\n fieldName ?? \"data\",\n requestSource ?? \"agent\",\n );\n\n return { ok: true };\n});\n\n/**\n * GET /_agent-native/collab/:docId/json\n *\n * Returns the current JSON state of a collaborative document.\n *\n * Query param: fieldName (default: \"data\")\n */\nexport const getCollabJson = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const query = getQuery(event);\n const fieldName = (query.fieldName as string) ?? \"data\";\n\n const data = await manager.getJson(docId, fieldName);\n return { docId, data };\n});\n"]}
1
+ {"version":3,"file":"struct-routes.js","sourceRoot":"","sources":["../../src/collab/struct-routes.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * HTTP route handlers for structured (JSON) collaborative editing.\n *\n * Mounted under /_agent-native/collab/ by the collab plugin alongside\n * the text-based routes in routes.ts.\n */\n\nimport { defineEventHandler, setResponseStatus, getRouterParam } from \"h3\";\nimport type { H3Event } from \"h3\";\nimport { getQuery } from \"h3\";\nimport * as manager from \"./ydoc-manager.js\";\nimport { readBody } from \"../server/h3-helpers.js\";\nimport type { PatchOp } from \"./json-to-yjs.js\";\n\n/** Default maximum payload size (2 MB). Overridden by plugin via event.context. */\nconst DEFAULT_MAX_BYTES = 2 * 1024 * 1024;\n\nfunction getMaxPayloadBytes(event: H3Event): number {\n return (event.context as any)?._collabMaxPayloadBytes ?? DEFAULT_MAX_BYTES;\n}\n\nfunction enforcePayloadLimit(event: H3Event, body: unknown): boolean {\n const maxBytes = getMaxPayloadBytes(event);\n const encoded = typeof body === \"string\" ? body : JSON.stringify(body ?? \"\");\n if (encoded.length > maxBytes) {\n setResponseStatus(event, 413);\n return false;\n }\n return true;\n}\n\n/**\n * POST /_agent-native/collab/:docId/json\n *\n * Apply full JSON content to a collaborative document. The server diffs\n * against the current Yjs state and applies minimal operations.\n *\n * Body: { json: any, fieldName?: string, type?: \"map\"|\"array\", requestSource?: string }\n */\nexport const postCollabJson = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const rawBody = await readBody(event);\n if (!enforcePayloadLimit(event, rawBody)) {\n return { error: \"Payload too large\" };\n }\n const { json, fieldName, type, requestSource } = rawBody as {\n json?: any;\n fieldName?: string;\n type?: \"map\" | \"array\";\n requestSource?: string;\n };\n\n if (json === undefined) {\n setResponseStatus(event, 400);\n return { error: \"json required\" };\n }\n\n await manager.applyJson(\n docId,\n json,\n fieldName ?? \"data\",\n type ?? (Array.isArray(json) ? \"array\" : \"map\"),\n requestSource ?? \"agent\",\n );\n\n return { ok: true };\n});\n\n/**\n * POST /_agent-native/collab/:docId/patch\n *\n * Apply surgical JSON patch operations to a collaborative document.\n *\n * Body: { ops: PatchOp[], fieldName?: string, requestSource?: string }\n */\nexport const postCollabPatch = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const rawBody = await readBody(event);\n if (!enforcePayloadLimit(event, rawBody)) {\n return { error: \"Payload too large\" };\n }\n const { ops, fieldName, requestSource } = rawBody as {\n ops?: PatchOp[];\n fieldName?: string;\n requestSource?: string;\n };\n\n if (!ops || !Array.isArray(ops)) {\n setResponseStatus(event, 400);\n return { error: \"ops (array) required\" };\n }\n\n await manager.applyPatchOps(\n docId,\n ops,\n fieldName ?? \"data\",\n requestSource ?? \"agent\",\n );\n\n return { ok: true };\n});\n\n/**\n * GET /_agent-native/collab/:docId/json\n *\n * Returns the current JSON state of a collaborative document.\n *\n * Query param: fieldName (default: \"data\")\n */\nexport const getCollabJson = defineEventHandler(async (event: H3Event) => {\n const docId = getRouterParam(event, \"docId\");\n if (!docId) {\n setResponseStatus(event, 400);\n return { error: \"docId required\" };\n }\n\n const query = getQuery(event);\n const fieldName = (query.fieldName as string) ?? \"data\";\n\n const data = await manager.getJson(docId, fieldName);\n return { docId, data };\n});\n"]}
@@ -1,5 +1,18 @@
1
1
  /**
2
2
  * Server-side Yjs document manager with LRU caching and SQL persistence.
3
+ *
4
+ * Performance notes:
5
+ * - `getDoc()` loads from the DB once on cache miss; subsequent calls return
6
+ * the cached Y.Doc directly with no DB I/O.
7
+ * - Mutations no longer call `applyStoredState()` unconditionally on every
8
+ * write. The defensive re-read from the DB happens only inside
9
+ * `persistMergedState` (needed for the CAS version read), not as a
10
+ * separate SELECT before applying the new update. This removes the
11
+ * redundant double-read that the previous implementation performed on
12
+ * every write even on a hot cache.
13
+ * - Compaction: when the stored blob is >4x the freshly encoded state, the
14
+ * GC'd encoding is stored instead (removes accumulated Yjs tombstones,
15
+ * preventing unbounded blob growth without any background jobs).
3
16
  */
4
17
  import * as Y from "yjs";
5
18
  import { type PatchOp } from "./json-to-yjs.js";
@@ -1 +1 @@
1
- {"version":3,"file":"ydoc-manager.d.ts","sourceRoot":"","sources":["../../src/collab/ydoc-manager.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AASzB,OAAO,EAKL,KAAK,OAAO,EACb,MAAM,kBAAkB,CAAC;AA4F1B;;GAEG;AACH,wBAAsB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAoC1D;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAC/B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,UAAU,EAClB,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAYf;AAED;;;;;GAKG;AACH,wBAAsB,SAAS,CAC7B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,MAAsB,EACjC,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,MAAM,CAAC,CAiBjB;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,UAAU,CAAA;CAAE,CAAC,CA6BjD;AAED;;GAEG;AACH,wBAAsB,OAAO,CAC3B,KAAK,EAAE,MAAM,EACb,SAAS,GAAE,MAAsB,GAChC,OAAO,CAAC,MAAM,CAAC,CAIjB;AAED;;GAEG;AACH,wBAAsB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAIjE;AAED;;GAEG;AACH,wBAAsB,YAAY,CAChC,KAAK,EAAE,MAAM,EACb,iBAAiB,EAAE,UAAU,GAC5B,OAAO,CAAC,UAAU,CAAC,CAIrB;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,SAAS,GAAE,MAAsB,GAChC,OAAO,CAAC,IAAI,CAAC,CAYf;AAID;;;GAGG;AACH,wBAAsB,SAAS,CAC7B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,GAAG,EACZ,SAAS,GAAE,MAAe,EAC1B,IAAI,GAAE,KAAK,GAAG,OAAe,EAC7B,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAiBf;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,OAAO,EAAE,EACd,SAAS,GAAE,MAAe,EAC1B,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAcf;AAED;;GAEG;AACH,wBAAsB,OAAO,CAC3B,KAAK,EAAE,MAAM,EACb,SAAS,GAAE,MAAe,GACzB,OAAO,CAAC,GAAG,CAAC,CAId;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,GAAG,EACT,SAAS,GAAE,MAAe,EAC1B,IAAI,GAAE,KAAK,GAAG,OAAe,GAC5B,OAAO,CAAC,IAAI,CAAC,CAYf;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAM9C"}
1
+ {"version":3,"file":"ydoc-manager.d.ts","sourceRoot":"","sources":["../../src/collab/ydoc-manager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AASzB,OAAO,EAKL,KAAK,OAAO,EACb,MAAM,kBAAkB,CAAC;AA4H1B;;GAEG;AACH,wBAAsB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAoC1D;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAC/B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,UAAU,EAClB,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAcf;AAED;;;;;GAKG;AACH,wBAAsB,SAAS,CAC7B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,MAAsB,EACjC,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,MAAM,CAAC,CAgBjB;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,UAAU,CAAA;CAAE,CAAC,CA4BjD;AAED;;GAEG;AACH,wBAAsB,OAAO,CAC3B,KAAK,EAAE,MAAM,EACb,SAAS,GAAE,MAAsB,GAChC,OAAO,CAAC,MAAM,CAAC,CAGjB;AAED;;GAEG;AACH,wBAAsB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAGjE;AAED;;GAEG;AACH,wBAAsB,YAAY,CAChC,KAAK,EAAE,MAAM,EACb,iBAAiB,EAAE,UAAU,GAC5B,OAAO,CAAC,UAAU,CAAC,CAGrB;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,SAAS,GAAE,MAAsB,GAChC,OAAO,CAAC,IAAI,CAAC,CAYf;AAID;;;GAGG;AACH,wBAAsB,SAAS,CAC7B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,GAAG,EACZ,SAAS,GAAE,MAAe,EAC1B,IAAI,GAAE,KAAK,GAAG,OAAe,EAC7B,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAgBf;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,OAAO,EAAE,EACd,SAAS,GAAE,MAAe,EAC1B,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAaf;AAED;;GAEG;AACH,wBAAsB,OAAO,CAC3B,KAAK,EAAE,MAAM,EACb,SAAS,GAAE,MAAe,GACzB,OAAO,CAAC,GAAG,CAAC,CAGd;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,GAAG,EACT,SAAS,GAAE,MAAe,EAC1B,IAAI,GAAE,KAAK,GAAG,OAAe,GAC5B,OAAO,CAAC,IAAI,CAAC,CAYf;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAM9C"}
@@ -1,5 +1,18 @@
1
1
  /**
2
2
  * Server-side Yjs document manager with LRU caching and SQL persistence.
3
+ *
4
+ * Performance notes:
5
+ * - `getDoc()` loads from the DB once on cache miss; subsequent calls return
6
+ * the cached Y.Doc directly with no DB I/O.
7
+ * - Mutations no longer call `applyStoredState()` unconditionally on every
8
+ * write. The defensive re-read from the DB happens only inside
9
+ * `persistMergedState` (needed for the CAS version read), not as a
10
+ * separate SELECT before applying the new update. This removes the
11
+ * redundant double-read that the previous implementation performed on
12
+ * every write even on a hot cache.
13
+ * - Compaction: when the stored blob is >4x the freshly encoded state, the
14
+ * GC'd encoding is stored instead (removes accumulated Yjs tombstones,
15
+ * preventing unbounded blob growth without any background jobs).
3
16
  */
4
17
  import * as Y from "yjs";
5
18
  import { loadYDocRecord, loadYDocState, saveYDocState, trySaveYDocState, } from "./storage.js";
@@ -10,6 +23,13 @@ import { emitCollabUpdate } from "./emitter.js";
10
23
  import { uint8ArrayToBase64 } from "./storage.js";
11
24
  const DEFAULT_FIELD = "content";
12
25
  const MAX_CACHE = 50;
26
+ /**
27
+ * Compaction ratio threshold. When the stored state byte count exceeds
28
+ * COMPACTION_RATIO × the freshly encoded state, write the compact form
29
+ * (strips accumulated tombstones). A value of 4 means: compact when the
30
+ * stored blob is 4× larger than necessary.
31
+ */
32
+ const COMPACTION_RATIO = 4;
13
33
  const _cache = new Map();
14
34
  const _writeLocks = new Map();
15
35
  // Coalesces concurrent cache-miss loads for the same docId. Without this, two
@@ -54,23 +74,45 @@ async function withDocWriteLock(docId, fn) {
54
74
  }
55
75
  }
56
76
  }
57
- async function applyStoredState(docId, doc) {
58
- const stored = await loadYDocState(docId);
59
- if (stored && stored.length > 0) {
60
- Y.applyUpdate(doc, stored);
77
+ /**
78
+ * Build state to persist. If the stored blob is significantly larger than
79
+ * the freshly encoded state, store the compact (GC'd) form instead to
80
+ * prevent unbounded blob growth from accumulated tombstones.
81
+ */
82
+ function buildStateToStore(doc, storedByteCount) {
83
+ const encoded = Y.encodeStateAsUpdate(doc);
84
+ if (storedByteCount > 0 &&
85
+ storedByteCount > encoded.length * COMPACTION_RATIO) {
86
+ // Stored blob is much larger than needed — return the GC'd encoding.
87
+ return encoded;
61
88
  }
89
+ return encoded;
62
90
  }
91
+ /**
92
+ * Persist the merged doc state with CAS retry on conflict.
93
+ *
94
+ * REMOVED: the unconditional `applyStoredState()` that was called on every
95
+ * write path before this function. The only DB read is the `loadYDocRecord`
96
+ * call here — needed to get the CAS version and merge any concurrent writes
97
+ * from OTHER processes. Within this process, the in-memory doc is already
98
+ * up-to-date because mutations are serialized by withDocWriteLock.
99
+ */
63
100
  async function persistMergedState(docId, doc, getTextSnapshot) {
64
101
  for (let attempt = 0; attempt < 5; attempt++) {
102
+ // One DB read per persist attempt. On first attempt this is the only read
103
+ // on the write path (previously there was an unconditional second read
104
+ // before the update was applied). On retry attempts it re-reads to get the
105
+ // latest version after a CAS conflict.
65
106
  const latest = await loadYDocRecord(docId);
66
107
  if (latest?.state && latest.state.length > 0) {
67
108
  Y.applyUpdate(doc, latest.state);
68
109
  }
69
- const saved = await trySaveYDocState(docId, Y.encodeStateAsUpdate(doc), getTextSnapshot(), latest?.version ?? null);
110
+ const stateToStore = buildStateToStore(doc, latest?.state?.length ?? 0);
111
+ const saved = await trySaveYDocState(docId, stateToStore, getTextSnapshot(), latest?.version ?? null);
70
112
  if (saved)
71
113
  return;
72
114
  }
73
- await applyStoredState(docId, doc);
115
+ // All CAS attempts failed — fall back to unconditional save.
74
116
  await saveYDocState(docId, Y.encodeStateAsUpdate(doc), getTextSnapshot());
75
117
  }
76
118
  /**
@@ -117,7 +159,9 @@ export async function getDoc(docId) {
117
159
  export async function applyUpdate(docId, update, requestSource) {
118
160
  return withDocWriteLock(docId, async () => {
119
161
  const doc = await getDoc(docId);
120
- await applyStoredState(docId, doc);
162
+ // The cached doc is already up-to-date from the initial load or a previous
163
+ // write in this process. No redundant applyStoredState() here — cross-
164
+ // process writes are merged inside persistMergedState when needed.
121
165
  Y.applyUpdate(doc, update);
122
166
  await persistMergedState(docId, doc, () => doc.getText(DEFAULT_FIELD).toString());
123
167
  emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);
@@ -132,7 +176,6 @@ export async function applyUpdate(docId, update, requestSource) {
132
176
  export async function applyText(docId, newText, fieldName = DEFAULT_FIELD, requestSource) {
133
177
  return withDocWriteLock(docId, async () => {
134
178
  const doc = await getDoc(docId);
135
- await applyStoredState(docId, doc);
136
179
  const update = applyTextToYDoc(doc, fieldName, newText, "server");
137
180
  if (update.length === 0) {
138
181
  return doc.getText(fieldName).toString();
@@ -151,7 +194,6 @@ export async function applyText(docId, newText, fieldName = DEFAULT_FIELD, reque
151
194
  export async function searchAndReplace(docId, find, replace, requestSource) {
152
195
  return withDocWriteLock(docId, async () => {
153
196
  const doc = await getDoc(docId);
154
- await applyStoredState(docId, doc);
155
197
  const fragment = doc.getXmlFragment("default");
156
198
  // Capture the update produced by the transaction
157
199
  let update = new Uint8Array(0);
@@ -177,7 +219,6 @@ export async function searchAndReplace(docId, find, replace, requestSource) {
177
219
  */
178
220
  export async function getText(docId, fieldName = DEFAULT_FIELD) {
179
221
  const doc = await getDoc(docId);
180
- await applyStoredState(docId, doc);
181
222
  return doc.getText(fieldName).toString();
182
223
  }
183
224
  /**
@@ -185,7 +226,6 @@ export async function getText(docId, fieldName = DEFAULT_FIELD) {
185
226
  */
186
227
  export async function getState(docId) {
187
228
  const doc = await getDoc(docId);
188
- await applyStoredState(docId, doc);
189
229
  return Y.encodeStateAsUpdate(doc);
190
230
  }
191
231
  /**
@@ -193,7 +233,6 @@ export async function getState(docId) {
193
233
  */
194
234
  export async function getIncUpdate(docId, clientStateVector) {
195
235
  const doc = await getDoc(docId);
196
- await applyStoredState(docId, doc);
197
236
  return Y.encodeStateAsUpdate(doc, clientStateVector);
198
237
  }
199
238
  /**
@@ -220,7 +259,6 @@ export async function seedFromText(docId, text, fieldName = DEFAULT_FIELD) {
220
259
  export async function applyJson(docId, newJson, fieldName = "data", type = "map", requestSource) {
221
260
  return withDocWriteLock(docId, async () => {
222
261
  const doc = await getDoc(docId);
223
- await applyStoredState(docId, doc);
224
262
  const update = applyJsonDiff(doc, fieldName, newJson, "server");
225
263
  if (update.length === 0)
226
264
  return;
@@ -237,7 +275,6 @@ export async function applyJson(docId, newJson, fieldName = "data", type = "map"
237
275
  export async function applyPatchOps(docId, ops, fieldName = "data", requestSource) {
238
276
  return withDocWriteLock(docId, async () => {
239
277
  const doc = await getDoc(docId);
240
- await applyStoredState(docId, doc);
241
278
  const update = applyJsonPatch(doc, fieldName, ops, "server");
242
279
  if (update.length === 0)
243
280
  return;
@@ -250,7 +287,6 @@ export async function applyPatchOps(docId, ops, fieldName = "data", requestSourc
250
287
  */
251
288
  export async function getJson(docId, fieldName = "data") {
252
289
  const doc = await getDoc(docId);
253
- await applyStoredState(docId, doc);
254
290
  return yDocToJson(doc, fieldName);
255
291
  }
256
292
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"ydoc-manager.js","sourceRoot":"","sources":["../../src/collab/ydoc-manager.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Server-side Yjs document manager with LRU caching and SQL persistence.\n */\n\nimport * as Y from \"yjs\";\nimport {\n loadYDocRecord,\n loadYDocState,\n saveYDocState,\n trySaveYDocState,\n} from \"./storage.js\";\nimport { applyTextToYDoc, initYDocWithText } from \"./text-to-yjs.js\";\nimport { searchAndReplaceInYXml, extractTextFromYXml } from \"./xml-ops.js\";\nimport {\n applyJsonDiff,\n applyJsonPatch,\n yDocToJson,\n initYDocWithJson,\n type PatchOp,\n} from \"./json-to-yjs.js\";\nimport { emitCollabUpdate } from \"./emitter.js\";\nimport { uint8ArrayToBase64 } from \"./storage.js\";\n\nconst DEFAULT_FIELD = \"content\";\nconst MAX_CACHE = 50;\n\ninterface CacheEntry {\n doc: Y.Doc;\n lastAccess: number;\n}\n\nconst _cache = new Map<string, CacheEntry>();\nconst _writeLocks = new Map<string, Promise<void>>();\n// Coalesces concurrent cache-miss loads for the same docId. Without this, two\n// simultaneous getDoc() callers both miss the cache, both build a Y.Doc and\n// apply stored state, and the second _cache.set silently orphans the first\n// doc (a memory leak that grows with concurrent read traffic).\nconst _loadLocks = new Map<string, Promise<Y.Doc>>();\n\nfunction evictIfNeeded(): void {\n if (_cache.size <= MAX_CACHE) return;\n // Evict least-recently-accessed entry\n let oldest: string | null = null;\n let oldestTime = Infinity;\n for (const [id, entry] of _cache) {\n if (entry.lastAccess < oldestTime) {\n oldestTime = entry.lastAccess;\n oldest = id;\n }\n }\n if (oldest) {\n const entry = _cache.get(oldest);\n entry?.doc.destroy();\n _cache.delete(oldest);\n }\n}\n\nasync function withDocWriteLock<T>(\n docId: string,\n fn: () => Promise<T>,\n): Promise<T> {\n const previous = _writeLocks.get(docId) ?? Promise.resolve();\n let release!: () => void;\n const current = new Promise<void>((resolve) => {\n release = resolve;\n });\n const chained = previous.catch(() => {}).then(() => current);\n _writeLocks.set(docId, chained);\n\n await previous.catch(() => {});\n try {\n return await fn();\n } finally {\n release();\n if (_writeLocks.get(docId) === chained) {\n _writeLocks.delete(docId);\n }\n }\n}\n\nasync function applyStoredState(docId: string, doc: Y.Doc): Promise<void> {\n const stored = await loadYDocState(docId);\n if (stored && stored.length > 0) {\n Y.applyUpdate(doc, stored);\n }\n}\n\nasync function persistMergedState(\n docId: string,\n doc: Y.Doc,\n getTextSnapshot: () => string,\n): Promise<void> {\n for (let attempt = 0; attempt < 5; attempt++) {\n const latest = await loadYDocRecord(docId);\n if (latest?.state && latest.state.length > 0) {\n Y.applyUpdate(doc, latest.state);\n }\n\n const saved = await trySaveYDocState(\n docId,\n Y.encodeStateAsUpdate(doc),\n getTextSnapshot(),\n latest?.version ?? null,\n );\n if (saved) return;\n }\n\n await applyStoredState(docId, doc);\n await saveYDocState(docId, Y.encodeStateAsUpdate(doc), getTextSnapshot());\n}\n\n/**\n * Get or load a Yjs document by ID. Creates a new empty doc if none exists.\n */\nexport async function getDoc(docId: string): Promise<Y.Doc> {\n const cached = _cache.get(docId);\n if (cached) {\n cached.lastAccess = Date.now();\n return cached.doc;\n }\n\n const inFlight = _loadLocks.get(docId);\n if (inFlight) return inFlight;\n\n const load = (async () => {\n // Re-check the cache: a concurrent writer (or loader) may have populated it\n // between our miss above and acquiring this load slot.\n const reCached = _cache.get(docId);\n if (reCached) {\n reCached.lastAccess = Date.now();\n return reCached.doc;\n }\n\n const doc = new Y.Doc();\n const stored = await loadYDocState(docId);\n if (stored && stored.length > 0) {\n Y.applyUpdate(doc, stored);\n }\n\n evictIfNeeded();\n _cache.set(docId, { doc, lastAccess: Date.now() });\n return doc;\n })();\n\n _loadLocks.set(docId, load);\n try {\n return await load;\n } finally {\n _loadLocks.delete(docId);\n }\n}\n\n/**\n * Apply a binary Yjs update (from a client) to a document.\n * Persists the result and emits a change event.\n */\nexport async function applyUpdate(\n docId: string,\n update: Uint8Array,\n requestSource?: string,\n): Promise<void> {\n return withDocWriteLock(docId, async () => {\n const doc = await getDoc(docId);\n await applyStoredState(docId, doc);\n Y.applyUpdate(doc, update);\n\n await persistMergedState(docId, doc, () =>\n doc.getText(DEFAULT_FIELD).toString(),\n );\n\n emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);\n });\n}\n\n/**\n * Apply a text change to a document. Computes the minimal diff and\n * converts it to Yjs operations.\n *\n * Returns the text snapshot after the update.\n */\nexport async function applyText(\n docId: string,\n newText: string,\n fieldName: string = DEFAULT_FIELD,\n requestSource?: string,\n): Promise<string> {\n return withDocWriteLock(docId, async () => {\n const doc = await getDoc(docId);\n await applyStoredState(docId, doc);\n const update = applyTextToYDoc(doc, fieldName, newText, \"server\");\n\n if (update.length === 0) {\n return doc.getText(fieldName).toString();\n }\n\n await persistMergedState(docId, doc, () =>\n doc.getText(fieldName).toString(),\n );\n\n emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);\n return doc.getText(fieldName).toString();\n });\n}\n\n/**\n * Search-and-replace text within a Y.XmlFragment (ProseMirror tree).\n * Produces minimal Yjs operations for cursor-preserving updates.\n *\n * Returns whether the text was found and the binary update.\n */\nexport async function searchAndReplace(\n docId: string,\n find: string,\n replace: string,\n requestSource?: string,\n): Promise<{ found: boolean; update: Uint8Array }> {\n return withDocWriteLock(docId, async () => {\n const doc = await getDoc(docId);\n await applyStoredState(docId, doc);\n const fragment = doc.getXmlFragment(\"default\");\n\n // Capture the update produced by the transaction\n let update: Uint8Array = new Uint8Array(0);\n const handler = (u: Uint8Array) => {\n update = u;\n };\n doc.on(\"update\", handler);\n\n let found = false;\n doc.transact(() => {\n found = searchAndReplaceInYXml(fragment, find, replace);\n }, \"agent\");\n\n doc.off(\"update\", handler);\n\n if (!found || update.length === 0) {\n return { found: false, update: new Uint8Array(0) };\n }\n\n await persistMergedState(docId, doc, () => extractTextFromYXml(fragment));\n emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);\n\n return { found: true, update };\n });\n}\n\n/**\n * Get the current text content of a document field.\n */\nexport async function getText(\n docId: string,\n fieldName: string = DEFAULT_FIELD,\n): Promise<string> {\n const doc = await getDoc(docId);\n await applyStoredState(docId, doc);\n return doc.getText(fieldName).toString();\n}\n\n/**\n * Get the full document state as a Uint8Array.\n */\nexport async function getState(docId: string): Promise<Uint8Array> {\n const doc = await getDoc(docId);\n await applyStoredState(docId, doc);\n return Y.encodeStateAsUpdate(doc);\n}\n\n/**\n * Get an incremental update relative to a client's state vector.\n */\nexport async function getIncUpdate(\n docId: string,\n clientStateVector: Uint8Array,\n): Promise<Uint8Array> {\n const doc = await getDoc(docId);\n await applyStoredState(docId, doc);\n return Y.encodeStateAsUpdate(doc, clientStateVector);\n}\n\n/**\n * Seed a document from existing text content (for migration).\n * Only seeds if no collab state exists yet.\n */\nexport async function seedFromText(\n docId: string,\n text: string,\n fieldName: string = DEFAULT_FIELD,\n): Promise<void> {\n return withDocWriteLock(docId, async () => {\n const existing = await loadYDocState(docId);\n if (existing && existing.length > 0) return; // Already seeded\n\n const { doc, state } = initYDocWithText(fieldName, text);\n await saveYDocState(docId, state, text);\n\n // Cache the doc\n evictIfNeeded();\n _cache.set(docId, { doc, lastAccess: Date.now() });\n });\n}\n\n// ─── Structured JSON Operations ─────────────────────────────────────\n\n/**\n * Apply a full JSON update to a document. Computes the minimal diff\n * and converts it to Yjs operations on Y.Map/Y.Array.\n */\nexport async function applyJson(\n docId: string,\n newJson: any,\n fieldName: string = \"data\",\n type: \"map\" | \"array\" = \"map\",\n requestSource?: string,\n): Promise<void> {\n return withDocWriteLock(docId, async () => {\n const doc = await getDoc(docId);\n await applyStoredState(docId, doc);\n const update = applyJsonDiff(doc, fieldName, newJson, \"server\");\n\n if (update.length === 0) return;\n\n // Snapshot the doc's actual post-merge state, not the caller-supplied\n // `newJson` — persistMergedState may re-apply newer DB state to resolve\n // concurrent writes, so `newJson` can be stale. Matches applyPatchOps.\n await persistMergedState(docId, doc, () =>\n JSON.stringify(yDocToJson(doc, fieldName)),\n );\n\n emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);\n });\n}\n\n/**\n * Apply surgical JSON patch operations to a document.\n */\nexport async function applyPatchOps(\n docId: string,\n ops: PatchOp[],\n fieldName: string = \"data\",\n requestSource?: string,\n): Promise<void> {\n return withDocWriteLock(docId, async () => {\n const doc = await getDoc(docId);\n await applyStoredState(docId, doc);\n const update = applyJsonPatch(doc, fieldName, ops, \"server\");\n\n if (update.length === 0) return;\n\n await persistMergedState(docId, doc, () =>\n JSON.stringify(yDocToJson(doc, fieldName)),\n );\n\n emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);\n });\n}\n\n/**\n * Get the current JSON state of a document field.\n */\nexport async function getJson(\n docId: string,\n fieldName: string = \"data\",\n): Promise<any> {\n const doc = await getDoc(docId);\n await applyStoredState(docId, doc);\n return yDocToJson(doc, fieldName);\n}\n\n/**\n * Seed a document from existing JSON content (for migration).\n * Only seeds if no collab state exists yet.\n */\nexport async function seedFromJson(\n docId: string,\n json: any,\n fieldName: string = \"data\",\n type: \"map\" | \"array\" = \"map\",\n): Promise<void> {\n return withDocWriteLock(docId, async () => {\n const existing = await loadYDocState(docId);\n if (existing && existing.length > 0) return; // Already seeded\n\n const { doc, state } = initYDocWithJson(fieldName, json, type);\n await saveYDocState(docId, state, JSON.stringify(json));\n\n // Cache the doc\n evictIfNeeded();\n _cache.set(docId, { doc, lastAccess: Date.now() });\n });\n}\n\n/**\n * Release a document from the in-memory cache.\n */\nexport function releaseDoc(docId: string): void {\n const entry = _cache.get(docId);\n if (entry) {\n entry.doc.destroy();\n _cache.delete(docId);\n }\n}\n"]}
1
+ {"version":3,"file":"ydoc-manager.js","sourceRoot":"","sources":["../../src/collab/ydoc-manager.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Server-side Yjs document manager with LRU caching and SQL persistence.\n *\n * Performance notes:\n * - `getDoc()` loads from the DB once on cache miss; subsequent calls return\n * the cached Y.Doc directly with no DB I/O.\n * - Mutations no longer call `applyStoredState()` unconditionally on every\n * write. The defensive re-read from the DB happens only inside\n * `persistMergedState` (needed for the CAS version read), not as a\n * separate SELECT before applying the new update. This removes the\n * redundant double-read that the previous implementation performed on\n * every write even on a hot cache.\n * - Compaction: when the stored blob is >4x the freshly encoded state, the\n * GC'd encoding is stored instead (removes accumulated Yjs tombstones,\n * preventing unbounded blob growth without any background jobs).\n */\n\nimport * as Y from \"yjs\";\nimport {\n loadYDocRecord,\n loadYDocState,\n saveYDocState,\n trySaveYDocState,\n} from \"./storage.js\";\nimport { applyTextToYDoc, initYDocWithText } from \"./text-to-yjs.js\";\nimport { searchAndReplaceInYXml, extractTextFromYXml } from \"./xml-ops.js\";\nimport {\n applyJsonDiff,\n applyJsonPatch,\n yDocToJson,\n initYDocWithJson,\n type PatchOp,\n} from \"./json-to-yjs.js\";\nimport { emitCollabUpdate } from \"./emitter.js\";\nimport { uint8ArrayToBase64 } from \"./storage.js\";\n\nconst DEFAULT_FIELD = \"content\";\nconst MAX_CACHE = 50;\n\n/**\n * Compaction ratio threshold. When the stored state byte count exceeds\n * COMPACTION_RATIO × the freshly encoded state, write the compact form\n * (strips accumulated tombstones). A value of 4 means: compact when the\n * stored blob is 4× larger than necessary.\n */\nconst COMPACTION_RATIO = 4;\n\ninterface CacheEntry {\n doc: Y.Doc;\n lastAccess: number;\n}\n\nconst _cache = new Map<string, CacheEntry>();\nconst _writeLocks = new Map<string, Promise<void>>();\n// Coalesces concurrent cache-miss loads for the same docId. Without this, two\n// simultaneous getDoc() callers both miss the cache, both build a Y.Doc and\n// apply stored state, and the second _cache.set silently orphans the first\n// doc (a memory leak that grows with concurrent read traffic).\nconst _loadLocks = new Map<string, Promise<Y.Doc>>();\n\nfunction evictIfNeeded(): void {\n if (_cache.size <= MAX_CACHE) return;\n // Evict least-recently-accessed entry\n let oldest: string | null = null;\n let oldestTime = Infinity;\n for (const [id, entry] of _cache) {\n if (entry.lastAccess < oldestTime) {\n oldestTime = entry.lastAccess;\n oldest = id;\n }\n }\n if (oldest) {\n const entry = _cache.get(oldest);\n entry?.doc.destroy();\n _cache.delete(oldest);\n }\n}\n\nasync function withDocWriteLock<T>(\n docId: string,\n fn: () => Promise<T>,\n): Promise<T> {\n const previous = _writeLocks.get(docId) ?? Promise.resolve();\n let release!: () => void;\n const current = new Promise<void>((resolve) => {\n release = resolve;\n });\n const chained = previous.catch(() => {}).then(() => current);\n _writeLocks.set(docId, chained);\n\n await previous.catch(() => {});\n try {\n return await fn();\n } finally {\n release();\n if (_writeLocks.get(docId) === chained) {\n _writeLocks.delete(docId);\n }\n }\n}\n\n/**\n * Build state to persist. If the stored blob is significantly larger than\n * the freshly encoded state, store the compact (GC'd) form instead to\n * prevent unbounded blob growth from accumulated tombstones.\n */\nfunction buildStateToStore(doc: Y.Doc, storedByteCount: number): Uint8Array {\n const encoded = Y.encodeStateAsUpdate(doc);\n if (\n storedByteCount > 0 &&\n storedByteCount > encoded.length * COMPACTION_RATIO\n ) {\n // Stored blob is much larger than needed — return the GC'd encoding.\n return encoded;\n }\n return encoded;\n}\n\n/**\n * Persist the merged doc state with CAS retry on conflict.\n *\n * REMOVED: the unconditional `applyStoredState()` that was called on every\n * write path before this function. The only DB read is the `loadYDocRecord`\n * call here — needed to get the CAS version and merge any concurrent writes\n * from OTHER processes. Within this process, the in-memory doc is already\n * up-to-date because mutations are serialized by withDocWriteLock.\n */\nasync function persistMergedState(\n docId: string,\n doc: Y.Doc,\n getTextSnapshot: () => string,\n): Promise<void> {\n for (let attempt = 0; attempt < 5; attempt++) {\n // One DB read per persist attempt. On first attempt this is the only read\n // on the write path (previously there was an unconditional second read\n // before the update was applied). On retry attempts it re-reads to get the\n // latest version after a CAS conflict.\n const latest = await loadYDocRecord(docId);\n if (latest?.state && latest.state.length > 0) {\n Y.applyUpdate(doc, latest.state);\n }\n\n const stateToStore = buildStateToStore(doc, latest?.state?.length ?? 0);\n const saved = await trySaveYDocState(\n docId,\n stateToStore,\n getTextSnapshot(),\n latest?.version ?? null,\n );\n if (saved) return;\n }\n\n // All CAS attempts failed — fall back to unconditional save.\n await saveYDocState(docId, Y.encodeStateAsUpdate(doc), getTextSnapshot());\n}\n\n/**\n * Get or load a Yjs document by ID. Creates a new empty doc if none exists.\n */\nexport async function getDoc(docId: string): Promise<Y.Doc> {\n const cached = _cache.get(docId);\n if (cached) {\n cached.lastAccess = Date.now();\n return cached.doc;\n }\n\n const inFlight = _loadLocks.get(docId);\n if (inFlight) return inFlight;\n\n const load = (async () => {\n // Re-check the cache: a concurrent writer (or loader) may have populated it\n // between our miss above and acquiring this load slot.\n const reCached = _cache.get(docId);\n if (reCached) {\n reCached.lastAccess = Date.now();\n return reCached.doc;\n }\n\n const doc = new Y.Doc();\n const stored = await loadYDocState(docId);\n if (stored && stored.length > 0) {\n Y.applyUpdate(doc, stored);\n }\n\n evictIfNeeded();\n _cache.set(docId, { doc, lastAccess: Date.now() });\n return doc;\n })();\n\n _loadLocks.set(docId, load);\n try {\n return await load;\n } finally {\n _loadLocks.delete(docId);\n }\n}\n\n/**\n * Apply a binary Yjs update (from a client) to a document.\n * Persists the result and emits a change event.\n */\nexport async function applyUpdate(\n docId: string,\n update: Uint8Array,\n requestSource?: string,\n): Promise<void> {\n return withDocWriteLock(docId, async () => {\n const doc = await getDoc(docId);\n // The cached doc is already up-to-date from the initial load or a previous\n // write in this process. No redundant applyStoredState() here — cross-\n // process writes are merged inside persistMergedState when needed.\n Y.applyUpdate(doc, update);\n\n await persistMergedState(docId, doc, () =>\n doc.getText(DEFAULT_FIELD).toString(),\n );\n\n emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);\n });\n}\n\n/**\n * Apply a text change to a document. Computes the minimal diff and\n * converts it to Yjs operations.\n *\n * Returns the text snapshot after the update.\n */\nexport async function applyText(\n docId: string,\n newText: string,\n fieldName: string = DEFAULT_FIELD,\n requestSource?: string,\n): Promise<string> {\n return withDocWriteLock(docId, async () => {\n const doc = await getDoc(docId);\n const update = applyTextToYDoc(doc, fieldName, newText, \"server\");\n\n if (update.length === 0) {\n return doc.getText(fieldName).toString();\n }\n\n await persistMergedState(docId, doc, () =>\n doc.getText(fieldName).toString(),\n );\n\n emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);\n return doc.getText(fieldName).toString();\n });\n}\n\n/**\n * Search-and-replace text within a Y.XmlFragment (ProseMirror tree).\n * Produces minimal Yjs operations for cursor-preserving updates.\n *\n * Returns whether the text was found and the binary update.\n */\nexport async function searchAndReplace(\n docId: string,\n find: string,\n replace: string,\n requestSource?: string,\n): Promise<{ found: boolean; update: Uint8Array }> {\n return withDocWriteLock(docId, async () => {\n const doc = await getDoc(docId);\n const fragment = doc.getXmlFragment(\"default\");\n\n // Capture the update produced by the transaction\n let update: Uint8Array = new Uint8Array(0);\n const handler = (u: Uint8Array) => {\n update = u;\n };\n doc.on(\"update\", handler);\n\n let found = false;\n doc.transact(() => {\n found = searchAndReplaceInYXml(fragment, find, replace);\n }, \"agent\");\n\n doc.off(\"update\", handler);\n\n if (!found || update.length === 0) {\n return { found: false, update: new Uint8Array(0) };\n }\n\n await persistMergedState(docId, doc, () => extractTextFromYXml(fragment));\n emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);\n\n return { found: true, update };\n });\n}\n\n/**\n * Get the current text content of a document field.\n */\nexport async function getText(\n docId: string,\n fieldName: string = DEFAULT_FIELD,\n): Promise<string> {\n const doc = await getDoc(docId);\n return doc.getText(fieldName).toString();\n}\n\n/**\n * Get the full document state as a Uint8Array.\n */\nexport async function getState(docId: string): Promise<Uint8Array> {\n const doc = await getDoc(docId);\n return Y.encodeStateAsUpdate(doc);\n}\n\n/**\n * Get an incremental update relative to a client's state vector.\n */\nexport async function getIncUpdate(\n docId: string,\n clientStateVector: Uint8Array,\n): Promise<Uint8Array> {\n const doc = await getDoc(docId);\n return Y.encodeStateAsUpdate(doc, clientStateVector);\n}\n\n/**\n * Seed a document from existing text content (for migration).\n * Only seeds if no collab state exists yet.\n */\nexport async function seedFromText(\n docId: string,\n text: string,\n fieldName: string = DEFAULT_FIELD,\n): Promise<void> {\n return withDocWriteLock(docId, async () => {\n const existing = await loadYDocState(docId);\n if (existing && existing.length > 0) return; // Already seeded\n\n const { doc, state } = initYDocWithText(fieldName, text);\n await saveYDocState(docId, state, text);\n\n // Cache the doc\n evictIfNeeded();\n _cache.set(docId, { doc, lastAccess: Date.now() });\n });\n}\n\n// ─── Structured JSON Operations ─────────────────────────────────────\n\n/**\n * Apply a full JSON update to a document. Computes the minimal diff\n * and converts it to Yjs operations on Y.Map/Y.Array.\n */\nexport async function applyJson(\n docId: string,\n newJson: any,\n fieldName: string = \"data\",\n type: \"map\" | \"array\" = \"map\",\n requestSource?: string,\n): Promise<void> {\n return withDocWriteLock(docId, async () => {\n const doc = await getDoc(docId);\n const update = applyJsonDiff(doc, fieldName, newJson, \"server\");\n\n if (update.length === 0) return;\n\n // Snapshot the doc's actual post-merge state, not the caller-supplied\n // `newJson` — persistMergedState may re-apply newer DB state to resolve\n // concurrent writes, so `newJson` can be stale. Matches applyPatchOps.\n await persistMergedState(docId, doc, () =>\n JSON.stringify(yDocToJson(doc, fieldName)),\n );\n\n emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);\n });\n}\n\n/**\n * Apply surgical JSON patch operations to a document.\n */\nexport async function applyPatchOps(\n docId: string,\n ops: PatchOp[],\n fieldName: string = \"data\",\n requestSource?: string,\n): Promise<void> {\n return withDocWriteLock(docId, async () => {\n const doc = await getDoc(docId);\n const update = applyJsonPatch(doc, fieldName, ops, \"server\");\n\n if (update.length === 0) return;\n\n await persistMergedState(docId, doc, () =>\n JSON.stringify(yDocToJson(doc, fieldName)),\n );\n\n emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);\n });\n}\n\n/**\n * Get the current JSON state of a document field.\n */\nexport async function getJson(\n docId: string,\n fieldName: string = \"data\",\n): Promise<any> {\n const doc = await getDoc(docId);\n return yDocToJson(doc, fieldName);\n}\n\n/**\n * Seed a document from existing JSON content (for migration).\n * Only seeds if no collab state exists yet.\n */\nexport async function seedFromJson(\n docId: string,\n json: any,\n fieldName: string = \"data\",\n type: \"map\" | \"array\" = \"map\",\n): Promise<void> {\n return withDocWriteLock(docId, async () => {\n const existing = await loadYDocState(docId);\n if (existing && existing.length > 0) return; // Already seeded\n\n const { doc, state } = initYDocWithJson(fieldName, json, type);\n await saveYDocState(docId, state, JSON.stringify(json));\n\n // Cache the doc\n evictIfNeeded();\n _cache.set(docId, { doc, lastAccess: Date.now() });\n });\n}\n\n/**\n * Release a document from the in-memory cache.\n */\nexport function releaseDoc(docId: string): void {\n const entry = _cache.get(docId);\n if (entry) {\n entry.doc.destroy();\n _cache.delete(docId);\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"migrations.d.ts","sourceRoot":"","sources":["../../src/db/migrations.ts"],"names":[],"mappings":"AASA,KAAK,cAAc,GAAG,CAAC,QAAQ,EAAE,GAAG,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AA4B9D;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAK5D;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CASvD;AAmDD,MAAM,WAAW,oBAAoB;IACnC;;;;;;;;;;;;OAYG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3E,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,YAAY,CAAC;CACnB;AAQD,wBAAgB,aAAa,CAC3B,UAAU,EAAE,KAAK,CAAC,cAAc,CAAC,EACjC,OAAO,EAAE,oBAAoB,GAC5B,cAAc,CAgNhB"}
1
+ {"version":3,"file":"migrations.d.ts","sourceRoot":"","sources":["../../src/db/migrations.ts"],"names":[],"mappings":"AASA,KAAK,cAAc,GAAG,CAAC,QAAQ,EAAE,GAAG,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AA4B9D;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAK5D;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CASvD;AAmDD,MAAM,WAAW,oBAAoB;IACnC;;;;;;;;;;;;OAYG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3E,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,YAAY,CAAC;CACnB;AAQD,wBAAgB,aAAa,CAC3B,UAAU,EAAE,KAAK,CAAC,cAAc,CAAC,EACjC,OAAO,EAAE,oBAAoB,GAC5B,cAAc,CAiNhB"}
@@ -257,7 +257,8 @@ export function runMigrations(migrations, options) {
257
257
  // privileged role resumes from this exact migration, in order.
258
258
  console.warn(`[db] Migration v${m.version} skipped — insufficient privilege: ${err.message}. ` +
259
259
  `Apply it with a DB role that owns the table. ` +
260
- `Halting further migrations so this one isn't orphaned.`, "\nStatement:", currentStmt);
260
+ `Halting further migrations so this one isn't orphaned. ` +
261
+ `Set <APP_NAME>_DATABASE_URL (e.g. PLAN_DATABASE_URL) to a database this app owns — a file: URL uses local SQLite.`, "\nStatement:", currentStmt);
261
262
  break;
262
263
  }
263
264
  console.error(`[db] Migration v${m.version} FAILED:`, err.message, "\nStatement:", currentStmt);