esoul-sdk 0.7.0 → 0.16.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 (67) hide show
  1. package/README.md +575 -153
  2. package/api-reference.md +3291 -0
  3. package/dist/assets.d.ts +72 -0
  4. package/dist/assets.js +139 -0
  5. package/dist/audience.d.ts +2 -0
  6. package/dist/audience.js +2 -0
  7. package/dist/bindings.d.ts +3 -0
  8. package/dist/bindings.js +3 -0
  9. package/dist/chart-font.d.ts +19 -0
  10. package/dist/chart-font.js +15 -0
  11. package/dist/chart.d.ts +83 -0
  12. package/dist/chart.js +247 -0
  13. package/dist/computer.d.ts +191 -0
  14. package/dist/computer.js +226 -0
  15. package/dist/db/client-core.d.ts +38 -0
  16. package/dist/db/client-core.js +68 -1
  17. package/dist/db/compile-rules.d.ts +60 -2
  18. package/dist/db/compile-rules.js +178 -30
  19. package/dist/db/custom-roles.d.ts +156 -0
  20. package/dist/db/custom-roles.js +280 -0
  21. package/dist/db/memory-client.d.ts +0 -15
  22. package/dist/db/memory-client.js +13 -23
  23. package/dist/db/schema-gen.js +2 -8
  24. package/dist/editor-sync.d.ts +130 -0
  25. package/dist/editor-sync.js +413 -0
  26. package/dist/files.d.ts +70 -0
  27. package/dist/files.js +48 -0
  28. package/dist/helpers.d.ts +10 -0
  29. package/dist/helpers.js +10 -0
  30. package/dist/index.d.ts +24 -0
  31. package/dist/index.js +25 -0
  32. package/dist/labelme.d.ts +84 -0
  33. package/dist/labelme.js +118 -0
  34. package/dist/manifest.d.ts +400 -106
  35. package/dist/manifest.js +59 -5
  36. package/dist/ops.d.ts +115 -0
  37. package/dist/ops.js +120 -0
  38. package/dist/react.d.ts +280 -4
  39. package/dist/react.js +100 -3
  40. package/dist/server.d.ts +268 -10
  41. package/dist/server.js +120 -4
  42. package/dist/testing/db.d.ts +6 -0
  43. package/dist/testing/db.js +3 -8
  44. package/dist/testing/files.d.ts +22 -0
  45. package/dist/testing/files.js +175 -0
  46. package/dist/testing/index.d.ts +2 -0
  47. package/dist/testing/index.js +1 -0
  48. package/dist/types.d.ts +47 -0
  49. package/docs/02-manifest.md +2 -0
  50. package/docs/03-events-and-state.md +8 -0
  51. package/docs/04-tools.md +58 -26
  52. package/docs/05-ui.md +35 -0
  53. package/docs/06-server.md +121 -7
  54. package/docs/07-background-tasks.md +7 -9
  55. package/docs/09-files.md +126 -17
  56. package/docs/10-testing.md +6 -0
  57. package/docs/11-shipping.md +10 -2
  58. package/docs/13-people-and-access.md +180 -0
  59. package/docs/14-database.md +6 -0
  60. package/docs/15-realtime.md +3 -0
  61. package/docs/17-editing-and-merging.md +155 -0
  62. package/llms-full.txt +1296 -214
  63. package/llms.txt +3 -3
  64. package/package.json +9 -4
  65. package/schemas/plugin.schema.json +134 -15
  66. package/scripts/build-api-reference.mjs +104 -0
  67. package/scripts/build-llms.mjs +1 -2
@@ -0,0 +1,130 @@
1
+ /**
2
+ * EDITOR SYNC — how a MOUNTED editor takes a change that arrived from elsewhere (another device, another
3
+ * tab, an agent) while the person may be typing. The pure half: one decision and the merges. SINGLE SOURCE:
4
+ * the platform re-exports this file (src/lib/editor-sync/reconcile.ts), so an app built with the SDK and a
5
+ * built-in app decide identically. The hook is `useRemoteReconcile` in `esoul-sdk/react`; the guide is
6
+ * docs/17-editing-and-merging.md (platform design: .cursor/rules/docs/editor-sync.md).
7
+ *
8
+ * Before this, each editor carried its own copy of the rules, and each copy had its own holes. The UI
9
+ * harness found three in block-notes alone:
10
+ * B1 the save's diff base was moved to the whole editor document after a merge, absorbing unsaved
11
+ * typing — the save then removed the other writer's content;
12
+ * B2 a merge offered to one editor instance was applied by the NEXT instance, over fresher content;
13
+ * B3 a fold that raced our own save was adopted, putting older text in front of the user.
14
+ * and the survey of the other editors found a fourth everywhere:
15
+ * B4 a remote change skipped while the user types (or while a save is pending) is overwritten by the
16
+ * next local snapshot save, and — because that save's causal base is the STORE's newest, not what
17
+ * the editor showed — the write fence accepts it silently.
18
+ *
19
+ * The rules, in order (decideReconcile):
20
+ * 1. incoming equals what the editor last knew (its base) → nothing to do (an echo);
21
+ * 2. incoming equals what the editor shows now → converged, the base moves;
22
+ * 3. the store holds an OLDER write of this session → a fold that raced our save: ignore (B3);
23
+ * 4. the editor can merge (base, mine, theirs) → merge now, keep typing, re-issue any
24
+ * pending save with the merged document
25
+ * (B1: the base becomes THEIRS, never
26
+ * the editor doc);
27
+ * 5. a local save is still pending → look again once it has gone out;
28
+ * 6. the user is editing and a merge is impossible → wait for idle, and until then every save
29
+ * is stamped with the base the editor
30
+ * really built on, so the fence marks a
31
+ * conflict instead of silently winning (B4);
32
+ * 7. otherwise → adopt.
33
+ * B2 is structural: all state lives in the editor instance (the hook's refs); nothing is offered across
34
+ * instances.
35
+ */
36
+ /** The write fence's per-item bookkeeping this module reads (apply-app-event.ts `ItemRevMeta`). */
37
+ export interface FenceItemMeta {
38
+ tip?: string;
39
+ ts?: number;
40
+ sess?: string;
41
+ }
42
+ /** The fence record of `itemKey` in an app state (`__itemRevs`), if any. */
43
+ export declare function fenceMetaOf(state: unknown, itemKey: string): FenceItemMeta | undefined;
44
+ /**
45
+ * Does the store hold a write of THIS session that is older than our last save? Then it is a fold that
46
+ * raced that save (a late poll, a collapse-merge read), not a remote change. Compared on this session's
47
+ * own clock only.
48
+ */
49
+ export declare function isOlderOwnWrite(meta: FenceItemMeta | undefined, sessionId: string, lastOwnSaveAt: number | null | undefined): boolean;
50
+ export type ReconcileDecision = {
51
+ kind: "echo";
52
+ } | {
53
+ kind: "converged";
54
+ } | {
55
+ kind: "stale-own";
56
+ } | {
57
+ kind: "wait-save";
58
+ } | {
59
+ kind: "merge";
60
+ } | {
61
+ kind: "wait-idle";
62
+ } | {
63
+ kind: "adopt";
64
+ };
65
+ /**
66
+ * What a mounted editor should do with the value the store now holds for the item it edits: echo, converged,
67
+ * stale-own, merge, wait-save, wait-idle or adopt (the rules above, in that order). Pure. `useRemoteReconcile`
68
+ * in esoul-sdk/react calls it for you; call it directly only to test your own adapter.
69
+ */
70
+ export declare function decideReconcile<T>(args: {
71
+ incoming: T;
72
+ /** What the editor last knew the server to hold (its merge base). */
73
+ base: T | undefined;
74
+ /** What the editor shows right now. */
75
+ current: T;
76
+ equal: (a: T, b: T) => boolean;
77
+ /** A local save is scheduled and not yet dispatched. */
78
+ pending: boolean;
79
+ /** The user is in the middle of editing (focus, an open field). */
80
+ editing: boolean;
81
+ /** isOlderOwnWrite(...) for the incoming state. */
82
+ olderOwn: boolean;
83
+ /** The editor supplies a three-way merge. */
84
+ canMerge: boolean;
85
+ /** May a merge be applied while the person is editing? Default true. An editor that can only apply by
86
+ * replacing its whole surface (an input in progress would be lost) sets false: it merges once idle. */
87
+ mergeWhileEditing?: boolean;
88
+ }): ReconcileDecision;
89
+ /** Structural equality over JSON values, object key order ignored. */
90
+ export declare function jsonEqual(a: unknown, b: unknown): boolean;
91
+ /**
92
+ * Three-way merge of a list of records addressed by id (blocks, pages, nodes, edges, tabs).
93
+ *
94
+ * A record changed on one side only takes that side; unchanged on both keeps base. Added on one side is
95
+ * kept; deleted on one side is dropped unless the OTHER side changed it (then the change survives).
96
+ * Changed differently on both sides is a real conflict → `null` (the caller falls back to waiting, and
97
+ * the fence records the conflict). Order: mine, with their inserts placed after their predecessor.
98
+ */
99
+ export declare function mergeRecordsById<R>(base: readonly R[], mine: readonly R[], theirs: readonly R[], idOf: (r: R) => string | undefined, equal?: (a: R, b: R) => boolean,
100
+ /** Both sides changed one record: merge the record itself (e.g. its text by lines); null = conflict. */
101
+ mergeRecord?: (base: R, mine: R, theirs: R) => R | null): R[] | null;
102
+ /**
103
+ * Three-way merge of a flat record of fields (a form: to/cc/subject/body; a node's config). A field
104
+ * changed differently on both sides is a conflict → `null`.
105
+ */
106
+ export declare function mergeFields<F extends Record<string, unknown>>(base: F, mine: F, theirs: F, equal?: (a: unknown, b: unknown) => boolean): F | null;
107
+ /**
108
+ * Three-way merge of a text by lines. Keeps both sides when they touched different lines; any overlap
109
+ * (the same base line range changed on both sides) is a conflict → `null`.
110
+ */
111
+ export declare function mergeLines(base: string, mine: string, theirs: string): string | null;
112
+ /**
113
+ * Three-way merge of any SEQUENCE (lines, paragraphs, list items without ids): the two sides' changed regions
114
+ * against the base are found by longest common subsequence on `keyOf`; regions that do not overlap are both
115
+ * applied (two inserts at the same point both stay, ordered by content so every side agrees), overlapping ones are a
116
+ * conflict → `null`. Long common heads and tails are trimmed first, so a large document with local edits stays
117
+ * cheap. Pure.
118
+ *
119
+ * `unify(a, b)`: when two inserts land at the same point, items that are VERSIONS of one item (a paragraph one
120
+ * side copied while it was still being typed) come out as one — return the item to keep, or null when a and b
121
+ * are different items. It must be symmetric (the same answer for (a, b) and (b, a)) so every side converges.
122
+ */
123
+ export declare function mergeSequences<T>(base: readonly T[], mine: readonly T[], theirs: readonly T[], keyOf: (item: T) => string, unify?: (a: T, b: T) => T | null): T[] | null;
124
+ /**
125
+ * Where a caret (or selection edge) belongs after the text it sat in was replaced by a merge: unchanged when
126
+ * the change is entirely after it, shifted by the length difference when the change is before it. Pure. Use it
127
+ * when your editor is a controlled textarea — otherwise React puts the caret at the end and the rest of the
128
+ * person's typing lands after the other writer's text.
129
+ */
130
+ export declare function mapCaretThroughEdit(prev: string, next: string, pos: number): number;
@@ -0,0 +1,413 @@
1
+ /**
2
+ * EDITOR SYNC — how a MOUNTED editor takes a change that arrived from elsewhere (another device, another
3
+ * tab, an agent) while the person may be typing. The pure half: one decision and the merges. SINGLE SOURCE:
4
+ * the platform re-exports this file (src/lib/editor-sync/reconcile.ts), so an app built with the SDK and a
5
+ * built-in app decide identically. The hook is `useRemoteReconcile` in `esoul-sdk/react`; the guide is
6
+ * docs/17-editing-and-merging.md (platform design: .cursor/rules/docs/editor-sync.md).
7
+ *
8
+ * Before this, each editor carried its own copy of the rules, and each copy had its own holes. The UI
9
+ * harness found three in block-notes alone:
10
+ * B1 the save's diff base was moved to the whole editor document after a merge, absorbing unsaved
11
+ * typing — the save then removed the other writer's content;
12
+ * B2 a merge offered to one editor instance was applied by the NEXT instance, over fresher content;
13
+ * B3 a fold that raced our own save was adopted, putting older text in front of the user.
14
+ * and the survey of the other editors found a fourth everywhere:
15
+ * B4 a remote change skipped while the user types (or while a save is pending) is overwritten by the
16
+ * next local snapshot save, and — because that save's causal base is the STORE's newest, not what
17
+ * the editor showed — the write fence accepts it silently.
18
+ *
19
+ * The rules, in order (decideReconcile):
20
+ * 1. incoming equals what the editor last knew (its base) → nothing to do (an echo);
21
+ * 2. incoming equals what the editor shows now → converged, the base moves;
22
+ * 3. the store holds an OLDER write of this session → a fold that raced our save: ignore (B3);
23
+ * 4. the editor can merge (base, mine, theirs) → merge now, keep typing, re-issue any
24
+ * pending save with the merged document
25
+ * (B1: the base becomes THEIRS, never
26
+ * the editor doc);
27
+ * 5. a local save is still pending → look again once it has gone out;
28
+ * 6. the user is editing and a merge is impossible → wait for idle, and until then every save
29
+ * is stamped with the base the editor
30
+ * really built on, so the fence marks a
31
+ * conflict instead of silently winning (B4);
32
+ * 7. otherwise → adopt.
33
+ * B2 is structural: all state lives in the editor instance (the hook's refs); nothing is offered across
34
+ * instances.
35
+ */
36
+ /** The fence record of `itemKey` in an app state (`__itemRevs`), if any. */
37
+ export function fenceMetaOf(state, itemKey) {
38
+ const revs = state?.__itemRevs;
39
+ return revs?.[itemKey];
40
+ }
41
+ /**
42
+ * Does the store hold a write of THIS session that is older than our last save? Then it is a fold that
43
+ * raced that save (a late poll, a collapse-merge read), not a remote change. Compared on this session's
44
+ * own clock only.
45
+ */
46
+ export function isOlderOwnWrite(meta, sessionId, lastOwnSaveAt) {
47
+ return (!!meta &&
48
+ meta.sess === sessionId &&
49
+ typeof meta.ts === "number" &&
50
+ typeof lastOwnSaveAt === "number" &&
51
+ meta.ts < lastOwnSaveAt);
52
+ }
53
+ /**
54
+ * What a mounted editor should do with the value the store now holds for the item it edits: echo, converged,
55
+ * stale-own, merge, wait-save, wait-idle or adopt (the rules above, in that order). Pure. `useRemoteReconcile`
56
+ * in esoul-sdk/react calls it for you; call it directly only to test your own adapter.
57
+ */
58
+ export function decideReconcile(args) {
59
+ const { incoming, base, current, equal } = args;
60
+ if (base !== undefined && equal(incoming, base))
61
+ return { kind: "echo" };
62
+ if (equal(incoming, current))
63
+ return { kind: "converged" };
64
+ if (args.olderOwn)
65
+ return { kind: "stale-own" };
66
+ // A mergeable editor merges now, pending save or not: the merged document carries the unsaved edits and
67
+ // the save is re-issued with it.
68
+ if (args.canMerge && base !== undefined && !(args.editing && args.mergeWhileEditing === false))
69
+ return { kind: "merge" };
70
+ if (args.pending)
71
+ return { kind: "wait-save" };
72
+ if (args.editing)
73
+ return { kind: "wait-idle" };
74
+ return { kind: "adopt" };
75
+ }
76
+ /* ─────────────────────────────── merges ─────────────────────────────── */
77
+ /** Structural equality over JSON values, object key order ignored. */
78
+ export function jsonEqual(a, b) {
79
+ if (a === b)
80
+ return true;
81
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== "object")
82
+ return false;
83
+ if (Array.isArray(a) !== Array.isArray(b))
84
+ return false;
85
+ if (Array.isArray(a)) {
86
+ const bb = b;
87
+ if (a.length !== bb.length)
88
+ return false;
89
+ for (let i = 0; i < a.length; i++)
90
+ if (!jsonEqual(a[i], bb[i]))
91
+ return false;
92
+ return true;
93
+ }
94
+ const ao = a;
95
+ const bo = b;
96
+ const ak = Object.keys(ao).filter((k) => ao[k] !== undefined);
97
+ const bk = Object.keys(bo).filter((k) => bo[k] !== undefined);
98
+ if (ak.length !== bk.length)
99
+ return false;
100
+ for (const k of ak)
101
+ if (!jsonEqual(ao[k], bo[k]))
102
+ return false;
103
+ return true;
104
+ }
105
+ /**
106
+ * Three-way merge of a list of records addressed by id (blocks, pages, nodes, edges, tabs).
107
+ *
108
+ * A record changed on one side only takes that side; unchanged on both keeps base. Added on one side is
109
+ * kept; deleted on one side is dropped unless the OTHER side changed it (then the change survives).
110
+ * Changed differently on both sides is a real conflict → `null` (the caller falls back to waiting, and
111
+ * the fence records the conflict). Order: mine, with their inserts placed after their predecessor.
112
+ */
113
+ export function mergeRecordsById(base, mine, theirs, idOf, equal = jsonEqual,
114
+ /** Both sides changed one record: merge the record itself (e.g. its text by lines); null = conflict. */
115
+ mergeRecord) {
116
+ const map = (list) => {
117
+ const m = new Map();
118
+ for (const r of list) {
119
+ const id = idOf(r);
120
+ if (id === undefined)
121
+ return null;
122
+ m.set(id, r);
123
+ }
124
+ return m;
125
+ };
126
+ const b = map(base), m = map(mine), t = map(theirs);
127
+ if (!b || !m || !t)
128
+ return null;
129
+ const out = new Map();
130
+ const ids = new Set([...m.keys(), ...t.keys(), ...b.keys()]);
131
+ for (const id of ids) {
132
+ const bv = b.get(id), mv = m.get(id), tv = t.get(id);
133
+ const mChanged = bv === undefined ? mv !== undefined : mv === undefined || !equal(bv, mv);
134
+ const tChanged = bv === undefined ? tv !== undefined : tv === undefined || !equal(bv, tv);
135
+ if (!mChanged && !tChanged) {
136
+ if (bv !== undefined)
137
+ out.set(id, bv);
138
+ continue;
139
+ }
140
+ if (mChanged && !tChanged) {
141
+ if (mv !== undefined)
142
+ out.set(id, mv);
143
+ continue;
144
+ }
145
+ if (!mChanged && tChanged) {
146
+ if (tv !== undefined)
147
+ out.set(id, tv);
148
+ continue;
149
+ }
150
+ // Both changed.
151
+ if (mv !== undefined && tv !== undefined && equal(mv, tv)) {
152
+ out.set(id, mv);
153
+ continue;
154
+ }
155
+ if (mergeRecord && bv !== undefined && mv !== undefined && tv !== undefined) {
156
+ const merged = mergeRecord(bv, mv, tv);
157
+ if (merged !== null) {
158
+ out.set(id, merged);
159
+ continue;
160
+ }
161
+ }
162
+ return null;
163
+ }
164
+ // Order: mine's order for what survives, then theirs' records inserted after their predecessor.
165
+ const order = [];
166
+ const placed = new Set();
167
+ for (const r of mine) {
168
+ const id = idOf(r);
169
+ if (out.has(id) && !placed.has(id)) {
170
+ order.push(id);
171
+ placed.add(id);
172
+ }
173
+ }
174
+ const theirIds = theirs.map((r) => idOf(r));
175
+ theirIds.forEach((id, i) => {
176
+ if (!out.has(id) || placed.has(id))
177
+ return;
178
+ let at = 0;
179
+ for (let j = i - 1; j >= 0; j--) {
180
+ const k = order.indexOf(theirIds[j]);
181
+ if (k >= 0) {
182
+ at = k + 1;
183
+ break;
184
+ }
185
+ }
186
+ order.splice(at, 0, id);
187
+ placed.add(id);
188
+ });
189
+ for (const id of out.keys())
190
+ if (!placed.has(id))
191
+ order.push(id);
192
+ return order.map((id) => out.get(id));
193
+ }
194
+ /**
195
+ * Three-way merge of a flat record of fields (a form: to/cc/subject/body; a node's config). A field
196
+ * changed differently on both sides is a conflict → `null`.
197
+ */
198
+ export function mergeFields(base, mine, theirs, equal = jsonEqual) {
199
+ const out = {};
200
+ const keys = new Set([...Object.keys(base), ...Object.keys(mine), ...Object.keys(theirs)]);
201
+ for (const k of keys) {
202
+ const bv = base[k], mv = mine[k], tv = theirs[k];
203
+ const mChanged = !equal(bv, mv), tChanged = !equal(bv, tv);
204
+ if (mChanged && tChanged && !equal(mv, tv))
205
+ return null;
206
+ const v = mChanged ? mv : tv;
207
+ if (v !== undefined)
208
+ out[k] = v;
209
+ }
210
+ return out;
211
+ }
212
+ /**
213
+ * Three-way merge of a text by lines. Keeps both sides when they touched different lines; any overlap
214
+ * (the same base line range changed on both sides) is a conflict → `null`.
215
+ */
216
+ export function mergeLines(base, mine, theirs) {
217
+ if (mine === theirs)
218
+ return mine;
219
+ if (base === mine)
220
+ return theirs;
221
+ if (base === theirs)
222
+ return mine;
223
+ const out = mergeSequences(base.split("\n"), mine.split("\n"), theirs.split("\n"), (l) => l);
224
+ return out === null ? null : out.join("\n");
225
+ }
226
+ /**
227
+ * Three-way merge of any SEQUENCE (lines, paragraphs, list items without ids): the two sides' changed regions
228
+ * against the base are found by longest common subsequence on `keyOf`; regions that do not overlap are both
229
+ * applied (two inserts at the same point both stay, ordered by content so every side agrees), overlapping ones are a
230
+ * conflict → `null`. Long common heads and tails are trimmed first, so a large document with local edits stays
231
+ * cheap. Pure.
232
+ *
233
+ * `unify(a, b)`: when two inserts land at the same point, items that are VERSIONS of one item (a paragraph one
234
+ * side copied while it was still being typed) come out as one — return the item to keep, or null when a and b
235
+ * are different items. It must be symmetric (the same answer for (a, b) and (b, a)) so every side converges.
236
+ */
237
+ export function mergeSequences(base, mine, theirs, keyOf, unify) {
238
+ const B = base.map(keyOf), M = mine.map(keyOf), Tk = theirs.map(keyOf);
239
+ const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
240
+ if (same(M, Tk))
241
+ return [...mine];
242
+ if (same(B, M))
243
+ return [...theirs];
244
+ if (same(B, Tk))
245
+ return [...mine];
246
+ const hm = hunks(B, M), ht = hunks(B, Tk);
247
+ // A real collision: two replaced ranges that intersect, or an insert strictly INSIDE a range the other side
248
+ // replaced. An insert right before or right after the other side's replaced range is unambiguous and merges
249
+ // (typing in the last paragraph while another device appends one after it). Two inserts at the SAME point
250
+ // both stay, in an order fixed by their content — every device and the server fold pick the same one — because
251
+ // refusing them lost one side's text outright (harness word-sync tabs: two tabs appending at the end).
252
+ const collide = (a, c) => {
253
+ const aIns = a.bStart === a.bEnd, cIns = c.bStart === c.bEnd;
254
+ if (aIns && cIns)
255
+ return false;
256
+ if (aIns)
257
+ return c.bStart < a.bStart && a.bStart < c.bEnd;
258
+ if (cIns)
259
+ return a.bStart < c.bStart && c.bStart < a.bEnd;
260
+ return a.bStart < c.bEnd && c.bStart < a.bEnd;
261
+ };
262
+ // The same change made on both sides (same base range, same new content) is one change, not a collision.
263
+ const sameChange = (a, c) => a.bStart === c.bStart && a.bEnd === c.bEnd && M.slice(a.sStart, a.sEnd).join("\u0000") === Tk.slice(c.sStart, c.sEnd).join("\u0000");
264
+ const dupT = new Set();
265
+ // With `unify`: both sides replaced the SAME base range with runs that share versions of one item (the paragraph
266
+ // one side copied mid-typing and the other finished) — one replacement, reconciled item by item.
267
+ const unifiedRuns = new Map();
268
+ for (const a of hm)
269
+ for (const c of ht) {
270
+ if (sameChange(a, c)) {
271
+ dupT.add(c);
272
+ continue;
273
+ }
274
+ if (unify && a.bStart === c.bStart && a.bEnd === c.bEnd && a.bEnd > a.bStart) {
275
+ const x = mine.slice(a.sStart, a.sEnd), y = theirs.slice(c.sStart, c.sEnd);
276
+ const xk = M.slice(a.sStart, a.sEnd).join("\u0000"), yk = Tk.slice(c.sStart, c.sEnd).join("\u0000");
277
+ const [first, second] = xk <= yk ? [x, y] : [y, x];
278
+ const joined = interleaveRuns(first, second, keyOf, unify);
279
+ if (joined.length < x.length + y.length) {
280
+ unifiedRuns.set(a, joined);
281
+ dupT.add(c);
282
+ continue;
283
+ }
284
+ }
285
+ if (collide(a, c))
286
+ return null;
287
+ }
288
+ const htKept = ht.filter((c) => !dupT.has(c));
289
+ const keysOfHunk = (h, src) => src.slice(h.sStart, h.sEnd).join("\u0000");
290
+ // Order by base position; at a tie an insert goes before the range that starts there, and two inserts by content.
291
+ const all = [...hm.map((h) => ({ ...h, src: mine, k: keysOfHunk(h, M) })), ...htKept.map((h) => ({ ...h, src: theirs, k: keysOfHunk(h, Tk) }))].sort((x, y) => x.bStart - y.bStart || (x.bEnd - x.bStart) - (y.bEnd - y.bStart) || (x.k < y.k ? -1 : x.k > y.k ? 1 : 0));
292
+ const out = [];
293
+ let i = 0;
294
+ for (let n = 0; n < all.length; n++) {
295
+ const h = all[n];
296
+ while (i < h.bStart)
297
+ out.push(base[i++]);
298
+ const next = all[n + 1];
299
+ if (h.bStart === h.bEnd && next && next.bStart === next.bEnd && next.bStart === h.bStart && next.src !== h.src) {
300
+ // Two inserts at one point: the same run is one insert; with `unify`, versions of one item are one item.
301
+ const x = h.src.slice(h.sStart, h.sEnd), y = next.src.slice(next.sStart, next.sEnd);
302
+ if (h.k === next.k)
303
+ out.push(...x);
304
+ else
305
+ out.push(...interleaveRuns(x, y, keyOf, unify));
306
+ n++;
307
+ continue;
308
+ }
309
+ const unified = h.src === mine ? unifiedRuns.get(hm.find((x) => x.bStart === h.bStart && x.bEnd === h.bEnd && x.sStart === h.sStart)) : undefined;
310
+ if (unified)
311
+ out.push(...unified);
312
+ else
313
+ for (let k = h.sStart; k < h.sEnd; k++)
314
+ out.push(h.src[k]);
315
+ i = Math.max(i, h.bEnd);
316
+ }
317
+ while (i < base.length)
318
+ out.push(base[i++]);
319
+ return out;
320
+ }
321
+ /** Two runs inserted at one point, x first (content order): items equal by key, or unified, appear once. */
322
+ function interleaveRuns(x, y, keyOf, unify) {
323
+ const match = (a, b) => (keyOf(a) === keyOf(b) ? a : unify ? unify(a, b) : null);
324
+ const n = x.length, m = y.length;
325
+ if (n * m > 250_000)
326
+ return [...x, ...y];
327
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
328
+ for (let i = n - 1; i >= 0; i--)
329
+ for (let j = m - 1; j >= 0; j--) {
330
+ dp[i][j] = match(x[i], y[j]) !== null ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
331
+ }
332
+ const out = [];
333
+ let i = 0, j = 0;
334
+ while (i < n || j < m) {
335
+ const u = i < n && j < m ? match(x[i], y[j]) : null;
336
+ if (u !== null && dp[i][j] === dp[i + 1][j + 1] + 1) {
337
+ out.push(u);
338
+ i++;
339
+ j++;
340
+ }
341
+ else if (i < n && (j >= m || dp[i + 1][j] >= dp[i][j + 1]))
342
+ out.push(x[i++]);
343
+ else
344
+ out.push(y[j++]);
345
+ }
346
+ return out;
347
+ }
348
+ /** Changed regions of `s` against base `b`: base [bStart,bEnd) replaced by s [sStart,sEnd). LCS-based, with the
349
+ * common head and tail trimmed first. */
350
+ function hunks(b, s) {
351
+ let head = 0;
352
+ while (head < b.length && head < s.length && b[head] === s[head])
353
+ head++;
354
+ let tail = 0;
355
+ while (tail < b.length - head && tail < s.length - head && b[b.length - 1 - tail] === s[s.length - 1 - tail])
356
+ tail++;
357
+ const bm = b.slice(head, b.length - tail), sm = s.slice(head, s.length - tail);
358
+ const n = bm.length, m = sm.length;
359
+ if (n === 0 && m === 0)
360
+ return [];
361
+ // LCS table over the changed middle (bail out to one whole hunk when it is huge).
362
+ if (n * m > 4_000_000)
363
+ return [{ bStart: head, bEnd: head + n, sStart: head, sEnd: head + m }];
364
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
365
+ for (let i = n - 1; i >= 0; i--)
366
+ for (let j = m - 1; j >= 0; j--) {
367
+ dp[i][j] = bm[i] === sm[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
368
+ }
369
+ const out = [];
370
+ let i = 0, j = 0;
371
+ let open = null;
372
+ const close = () => { if (open) {
373
+ out.push({ bStart: head + open.bStart, bEnd: head + i, sStart: head + open.sStart, sEnd: head + j });
374
+ open = null;
375
+ } };
376
+ while (i < n || j < m) {
377
+ if (i < n && j < m && bm[i] === sm[j]) {
378
+ close();
379
+ i++;
380
+ j++;
381
+ continue;
382
+ }
383
+ if (!open)
384
+ open = { bStart: i, sStart: j };
385
+ if (j < m && (i >= n || dp[i][j + 1] >= dp[i + 1][j]))
386
+ j++;
387
+ else
388
+ i++;
389
+ }
390
+ close();
391
+ return out;
392
+ }
393
+ /**
394
+ * Where a caret (or selection edge) belongs after the text it sat in was replaced by a merge: unchanged when
395
+ * the change is entirely after it, shifted by the length difference when the change is before it. Pure. Use it
396
+ * when your editor is a controlled textarea — otherwise React puts the caret at the end and the rest of the
397
+ * person's typing lands after the other writer's text.
398
+ */
399
+ export function mapCaretThroughEdit(prev, next, pos) {
400
+ let p = 0;
401
+ const n = Math.min(prev.length, next.length);
402
+ while (p < n && prev.charCodeAt(p) === next.charCodeAt(p))
403
+ p++;
404
+ if (pos <= p)
405
+ return pos;
406
+ let q = 0;
407
+ while (q < n - p && prev.charCodeAt(prev.length - 1 - q) === next.charCodeAt(next.length - 1 - q))
408
+ q++;
409
+ // pos inside the unchanged tail → keep its distance from the end; inside the changed region → end of the change.
410
+ if (prev.length - pos <= q)
411
+ return next.length - (prev.length - pos);
412
+ return next.length - q;
413
+ }
package/dist/files.d.ts CHANGED
@@ -38,11 +38,14 @@ export interface FileEntry {
38
38
  path?: string;
39
39
  }
40
40
  export type FileSourceErrorKind = "not_declared" | "source_unavailable" | "not_found" | "too_large" | "read_only" | "disabled" | "bad_ref";
41
+ /** A files call refused — `kind` says why: not_declared (the manifest has no `files` grant for it), source_unavailable, not_found, too_large, read_only, disabled, bad_ref. */
41
42
  export declare class FileSourceError extends Error {
42
43
  readonly kind: FileSourceErrorKind;
43
44
  constructor(kind: FileSourceErrorKind, message: string);
44
45
  }
46
+ /** The default most `FilesApi.read` returns (25 MB); pass `capBytes` to raise it. */
45
47
  export declare const FILE_READ_CAP_BYTES: number;
48
+ /** The ceiling `capBytes` may raise a read to (100 MB). */
46
49
  export declare const FILE_READ_HARD_CAP_BYTES: number;
47
50
  export type ParsedSourceId = {
48
51
  kind: "workspace";
@@ -56,4 +59,71 @@ export type ParsedSourceId = {
56
59
  providerKey: string;
57
60
  connectionId: string;
58
61
  };
62
+ /** A file source id as its parts: the workspace, Google Drive, a local mount, or an app's own provider. */
59
63
  export declare function parseSourceId(sourceId: string): ParsedSourceId;
64
+ /** Narrow a listing at the SOURCE — a folder of 5,000 files is filtered by Drive, not by your UI. */
65
+ export interface FileListOptions {
66
+ /** Only folders (a path autocomplete) or only files. */
67
+ only?: "file" | "folder";
68
+ /** Case-insensitive substring of the name. */
69
+ nameContains?: string;
70
+ /** "name" = natural name order (frame_2 before frame_10); "modified" = newest first. Default: the provider's own. */
71
+ orderBy?: "name" | "modified";
72
+ /** Entries per page, 1–1000 (default 100). */
73
+ pageSize?: number;
74
+ }
75
+ /** The most one page of a listing holds. */
76
+ export declare const FILE_LIST_PAGE_MAX = 1000;
77
+ /** The most `listAll` gathers before it answers `truncated: true`. */
78
+ export declare const FILE_LIST_ALL_MAX = 20000;
79
+ /** The most `readMany` reads in one call, in files and in bytes. */
80
+ export declare const FILE_READ_MANY_MAX = 200;
81
+ /** …and the most bytes it gathers in one call (64 MB); past it the remaining rows answer `too_large`. */
82
+ export declare const FILE_READ_MANY_TOTAL_BYTES: number;
83
+ /** The longest a read grant lives (24 h); the default is one hour. */
84
+ export declare const FILE_GRANT_MAX_TTL_SECONDS: number;
85
+ /** How long a read grant lives when `ttlSeconds` is not given: one hour. */
86
+ export declare const FILE_GRANT_DEFAULT_TTL_SECONDS: number;
87
+ /** What `write` answers: the file as it now stands, and whether it was made or replaced. */
88
+ export interface FileWriteResult {
89
+ entry: FileEntry;
90
+ created: boolean;
91
+ }
92
+ /** One answer of `readMany` — flat: `ok` true carries the bytes, false carries `kind` and `error`. */
93
+ export interface FileReadManyItem {
94
+ ref: FileRef;
95
+ ok: boolean;
96
+ name?: string;
97
+ contentType?: string;
98
+ /** The file's bytes as UTF-8 text (`as: "text"`, the default) … */
99
+ text?: string;
100
+ /** … or base64 (`as: "base64"`). */
101
+ base64?: string;
102
+ kind?: FileSourceErrorKind;
103
+ error?: string;
104
+ }
105
+ /**
106
+ * A READ GRANT: a signed, expiring permission to fetch files of ONE source of ONE
107
+ * workspace by URL, with no session — for an `<img>` in a preview, and for a machine
108
+ * (the person's computer downloading a training set). Build each file's URL with
109
+ * `fileGrantUrl`. Treat it like a password until it expires.
110
+ */
111
+ export interface FileReadGrant {
112
+ sourceId: FileSourceId;
113
+ /** Everything but the file: `…/api/file-sources/raw?ws=…&src=…&t=…`. */
114
+ urlBase: string;
115
+ /** Epoch ms. */
116
+ expiresAt: number;
117
+ }
118
+ /** The URL of one file under a grant. `width` asks for a downsized image (a thumbnail), 16–2048 px. */
119
+ export declare function fileGrantUrl(grant: Pick<FileReadGrant, "urlBase">, ref: FileRef | string, opts?: {
120
+ width?: number;
121
+ }): string;
122
+ /** Does an entry pass these listing options? The one rule every provider's listing is held to. */
123
+ export declare function matchesListOptions(entry: Pick<FileEntry, "name" | "kind">, opts?: FileListOptions | null): boolean;
124
+ /** `pageSize` as a provider may use it: a whole number from 1 to FILE_LIST_PAGE_MAX, default 100. */
125
+ export declare function listPageSize(opts?: FileListOptions | null): number;
126
+ /** "sheets/quality_dataset/Type5" → its segments. Leading "/", a source's own label and empty parts are dropped. */
127
+ export declare function splitFilePath(path: string): string[];
128
+ /** Natural name order: "frame_2" before "frame_10". */
129
+ export declare function compareFileNames(a: string, b: string): number;