@drakkar.software/octospaces-sdk 0.1.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Single dispatch point for live-sync events from a global SSE connection.
3
+ *
4
+ * When a server-sent event arrives, the unread/notification layer calls
5
+ * `dispatchDocChange(docPath)`:
6
+ * - if a hook has registered a pull for that path → call it (the user is
7
+ * actively viewing that doc) and return `true` — the caller skips the
8
+ * unread bump.
9
+ * - otherwise return `false` → the caller bumps unread.
10
+ *
11
+ * Hooks register/unregister via `registerPull`. SSE connection health is
12
+ * broadcast via `emitSseStatus` so hooks can gate their fallback polling.
13
+ *
14
+ * Call `clearLiveSyncBus()` on account switch to flush all registrations.
15
+ */
16
+
17
+ type PullFn = () => void;
18
+ type StatusListener = (up: boolean) => void;
19
+
20
+ const pullRegistry = new Map<string, PullFn>();
21
+ const statusListeners = new Set<StatusListener>();
22
+ let sseUp = false;
23
+
24
+ /**
25
+ * Register a pull function keyed by `docPath`. Returns an unsubscribe
26
+ * function — call it when the hook unmounts.
27
+ */
28
+ export function registerPull(docPath: string, fn: PullFn): () => void {
29
+ pullRegistry.set(docPath, fn);
30
+ return () => {
31
+ if (pullRegistry.get(docPath) === fn) pullRegistry.delete(docPath);
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Dispatch a doc-change event. If a pull is registered for `docPath`, calls
37
+ * it and returns `true`. Returns `false` if no listener is registered
38
+ * (the caller should bump unread).
39
+ */
40
+ export function dispatchDocChange(docPath: string): boolean {
41
+ const pull = pullRegistry.get(docPath);
42
+ if (!pull) return false;
43
+ pull();
44
+ return true;
45
+ }
46
+
47
+ /** Broadcast the current SSE health to all subscribers. */
48
+ export function emitSseStatus(up: boolean): void {
49
+ sseUp = up;
50
+ for (const l of statusListeners) l(up);
51
+ }
52
+
53
+ /**
54
+ * Subscribe to SSE health changes. Fires immediately with the current state.
55
+ * Returns an unsubscribe function.
56
+ */
57
+ export function onSseStatus(cb: StatusListener): () => void {
58
+ statusListeners.add(cb);
59
+ cb(sseUp);
60
+ return () => statusListeners.delete(cb);
61
+ }
62
+
63
+ /**
64
+ * Flush all registered doc pulls and reset SSE health. Call on account
65
+ * switch. `statusListeners` are React subscriptions that self-unsubscribe on
66
+ * unmount and are intentionally left intact.
67
+ */
68
+ export function clearLiveSyncBus(): void {
69
+ pullRegistry.clear();
70
+ sseUp = false;
71
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { matchTitle, rankResults, fold, isWordStart } from './search-match.js';
3
+
4
+ // ── fold ──────────────────────────────────────────────────────────────────────
5
+
6
+ describe('fold', () => {
7
+ it('lowercases ASCII', () => expect(fold('Hello World')).toBe('hello world'));
8
+ it('strips diacritics (é → e, ñ → n)', () => {
9
+ expect(fold('café')).toBe('cafe');
10
+ expect(fold('mañana')).toBe('manana');
11
+ });
12
+ it('preserves string length after stripping diacritics', () => {
13
+ const s = 'crêpe';
14
+ expect(fold(s).length).toBe(s.length);
15
+ });
16
+ it('passes through surrogate pairs unchanged', () => {
17
+ const emoji = '🐙notes';
18
+ expect(fold(emoji).length).toBe(emoji.length);
19
+ });
20
+ });
21
+
22
+ // ── isWordStart ───────────────────────────────────────────────────────────────
23
+
24
+ describe('isWordStart', () => {
25
+ it('position 0 is always a word start', () => expect(isWordStart('hello', 0)).toBe(true));
26
+ it('letter after a space is a word start', () => expect(isWordStart('hello world', 6)).toBe(true));
27
+ it('letter after another letter is not', () => expect(isWordStart('hello', 2)).toBe(false));
28
+ it('letter after a dash is a word start', () => expect(isWordStart('hello-world', 6)).toBe(true));
29
+ });
30
+
31
+ // ── matchTitle tiers ──────────────────────────────────────────────────────────
32
+
33
+ describe('matchTitle', () => {
34
+ it('returns null for empty query', () => expect(matchTitle('', 'Notes')).toBeNull());
35
+ it('returns null on a miss', () => expect(matchTitle('xyz', 'Notes')).toBeNull());
36
+
37
+ it('PREFIX tier — title starts with query', () => {
38
+ const m = matchTitle('not', 'Notes');
39
+ expect(m).not.toBeNull();
40
+ expect(m!.score).toBeGreaterThanOrEqual(3900);
41
+ expect(m!.ranges).toEqual([{ start: 0, end: 3 }]);
42
+ });
43
+
44
+ it('WORD tier — query matches at a word boundary', () => {
45
+ const m = matchTitle('pa', 'New page');
46
+ expect(m).not.toBeNull();
47
+ expect(m!.score).toBeGreaterThanOrEqual(2900);
48
+ expect(m!.score).toBeLessThan(4000);
49
+ expect(m!.ranges[0]!.start).toBe(4); // 'p' in "page"
50
+ });
51
+
52
+ it('SUBSTRING tier — query appears mid-word', () => {
53
+ const m = matchTitle('page', 'Homepage');
54
+ expect(m).not.toBeNull();
55
+ expect(m!.score).toBeGreaterThanOrEqual(1900);
56
+ expect(m!.score).toBeLessThan(3000);
57
+ });
58
+
59
+ it('FUZZY tier — query is a subsequence', () => {
60
+ const m = matchTitle('rdm', 'Roadmap');
61
+ expect(m).not.toBeNull();
62
+ expect(m!.score).toBeGreaterThanOrEqual(900);
63
+ expect(m!.score).toBeLessThan(2000);
64
+ });
65
+
66
+ it('PREFIX beats WORD beats SUBSTRING beats FUZZY', () => {
67
+ const prefix = matchTitle('no', 'Notes')!.score;
68
+ const word = matchTitle('pa', 'New page')!.score;
69
+ const substr = matchTitle('age', 'Homepage')!.score;
70
+ const fuzzy = matchTitle('rdm', 'Roadmap')!.score;
71
+ expect(prefix).toBeGreaterThan(word);
72
+ expect(word).toBeGreaterThan(substr);
73
+ expect(substr).toBeGreaterThan(fuzzy);
74
+ });
75
+
76
+ it('match is case-insensitive', () => {
77
+ expect(matchTitle('NOTE', 'notes')).not.toBeNull();
78
+ expect(matchTitle('note', 'NOTES')).not.toBeNull();
79
+ });
80
+
81
+ it('match is diacritic-insensitive', () => {
82
+ expect(matchTitle('cafe', 'café au lait')).not.toBeNull();
83
+ expect(matchTitle('creme', 'crème brûlée')).not.toBeNull();
84
+ });
85
+
86
+ it('fuzzy: spaces in query are ignored', () => {
87
+ expect(matchTitle('new pg', 'New page')).not.toBeNull();
88
+ });
89
+
90
+ it('fuzzy: adjacent hits merge into one range', () => {
91
+ const m = matchTitle('ab', 'xaby');
92
+ expect(m).not.toBeNull();
93
+ // 'ab' appears at position 1 as a substring; but if it fell through to fuzzy
94
+ // the two chars 'a' and 'b' at positions 1 and 2 would be one merged range.
95
+ expect(m!.ranges.length).toBe(1);
96
+ });
97
+
98
+ it('range spans index into original title (not folded)', () => {
99
+ const title = 'New Page';
100
+ const m = matchTitle('pa', title);
101
+ expect(m).not.toBeNull();
102
+ const { start, end } = m!.ranges[0]!;
103
+ expect(title.slice(start, end).toLowerCase()).toBe('pa');
104
+ });
105
+ });
106
+
107
+ // ── rankResults ───────────────────────────────────────────────────────────────
108
+
109
+ const NOW = 1_700_000_000_000;
110
+
111
+ function item(title: string, updatedAt = NOW) {
112
+ return { title, updatedAt };
113
+ }
114
+
115
+ describe('rankResults', () => {
116
+ it('returns empty array for empty list', () => {
117
+ expect(rankResults('test', [])).toEqual([]);
118
+ });
119
+
120
+ it('drops items that do not match', () => {
121
+ const results = rankResults('xyz', [item('Notes'), item('Roadmap')]);
122
+ expect(results).toHaveLength(0);
123
+ });
124
+
125
+ it('higher-tier match comes first', () => {
126
+ // "Notes" is a prefix match for "not"; "Homepage" is only a substring match
127
+ const results = rankResults('not', [item('Homepage notation'), item('Notes')]);
128
+ expect(results[0]!.item.title).toBe('Notes');
129
+ });
130
+
131
+ it('recency breaks score ties', () => {
132
+ const old = item('Notes', NOW - 10_000);
133
+ const recent = item('Notes', NOW);
134
+ const results = rankResults('not', [old, recent]);
135
+ expect(results[0]!.item).toBe(recent);
136
+ });
137
+
138
+ it('respects limit', () => {
139
+ const items = Array.from({ length: 10 }, (_, i) => item(`note ${i}`));
140
+ const results = rankResults('note', items, 3);
141
+ expect(results).toHaveLength(3);
142
+ });
143
+
144
+ it('each result carries score and ranges', () => {
145
+ const results = rankResults('note', [item('Notes')]);
146
+ expect(results[0]!.score).toBeGreaterThan(0);
147
+ expect(results[0]!.ranges.length).toBeGreaterThan(0);
148
+ });
149
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Pure title matcher + ranker for Quick Find / Search. No React, no I/O.
3
+ *
4
+ * Relevance is tiered the way a human reads a match, strongest first:
5
+ *
6
+ * 1. PREFIX — the title starts with the query ("not" → "Notes").
7
+ * 2. WORD boundary — some word starts with the query ("pa" → "New page").
8
+ * 3. SUBSTRING — the query appears mid-word ("page" → "Homepage").
9
+ * 4. FUZZY — the query is a subsequence ("rdm" → "Roadmap").
10
+ *
11
+ * Within a tier, earlier and tighter matches in shorter titles score higher;
12
+ * tier gaps are wider than any intra-tier penalty, so a fuzzy hit can never
13
+ * outrank a real substring. Ties (same score) fall back to `updatedAt` DESC in
14
+ * {@link rankResults} — between two objects named "Notes", the one touched last
15
+ * is almost always the one wanted.
16
+ *
17
+ * Matching is case- and diacritic-insensitive via a per-UTF-16-unit fold that
18
+ * PRESERVES STRING LENGTH, so the returned ranges index straight into the
19
+ * ORIGINAL title for highlight rendering.
20
+ */
21
+
22
+ /** Half-open [start, end) span into the original title. */
23
+ export interface MatchRange {
24
+ start: number;
25
+ end: number;
26
+ }
27
+
28
+ export interface TitleMatch {
29
+ score: number;
30
+ ranges: MatchRange[];
31
+ }
32
+
33
+ // Tier bases. Gaps (1000) exceed the max intra-tier penalty (≤900), keeping
34
+ // tiers strictly ordered no matter the inputs.
35
+ const TIER_PREFIX = 4000;
36
+ const TIER_WORD = 3000;
37
+ const TIER_SUBSTRING = 2000;
38
+ const TIER_FUZZY = 1000;
39
+
40
+ /**
41
+ * Lowercase + strip diacritics WITHOUT changing length: each UTF-16 unit maps
42
+ * to exactly one folded unit (NFD base char, first lowercase unit). Surrogate
43
+ * halves pass through unchanged — they can't match an ASCII query, which is
44
+ * exactly right for emoji-bearing titles.
45
+ */
46
+ export function fold(s: string): string {
47
+ let out = '';
48
+ for (let i = 0; i < s.length; i++) {
49
+ const base = s[i]!.normalize('NFD')[0]!;
50
+ const lower = base.toLowerCase();
51
+ // Some locale-specific lowercasings expand (e.g. 'İ' → 'i̇'); keep unit 0.
52
+ out += lower.length === 1 ? lower : lower[0]!;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** A word starts where the previous folded char is not alphanumeric. */
58
+ export function isWordStart(folded: string, i: number): boolean {
59
+ if (i === 0) return true;
60
+ return !/[a-z0-9]/.test(folded[i - 1]!);
61
+ }
62
+
63
+ const startPenalty = (i: number) => Math.min(i * 8, 600);
64
+ const lengthPenalty = (titleLen: number, queryLen: number) => Math.min(Math.max(titleLen - queryLen, 0), 100);
65
+
66
+ /**
67
+ * Match one title against a query. Returns `null` for an empty query or a miss.
68
+ * Ranges cover every highlighted span (one for contiguous tiers, several merged
69
+ * runs for fuzzy).
70
+ */
71
+ export function matchTitle(query: string, title: string): TitleMatch | null {
72
+ const q = fold(query.trim());
73
+ if (!q) return null;
74
+ const t = fold(title);
75
+
76
+ let first = -1;
77
+ let wordAt = -1;
78
+ for (let i = t.indexOf(q); i !== -1; i = t.indexOf(q, i + 1)) {
79
+ if (first === -1) first = i;
80
+ if (isWordStart(t, i)) {
81
+ wordAt = i;
82
+ break;
83
+ }
84
+ }
85
+
86
+ if (first === 0) {
87
+ return { score: TIER_PREFIX - lengthPenalty(t.length, q.length), ranges: [{ start: 0, end: q.length }] };
88
+ }
89
+ if (wordAt !== -1) {
90
+ return {
91
+ score: TIER_WORD - startPenalty(wordAt) - lengthPenalty(t.length, q.length),
92
+ ranges: [{ start: wordAt, end: wordAt + q.length }],
93
+ };
94
+ }
95
+ if (first !== -1) {
96
+ return {
97
+ score: TIER_SUBSTRING - startPenalty(first) - lengthPenalty(t.length, q.length),
98
+ ranges: [{ start: first, end: first + q.length }],
99
+ };
100
+ }
101
+
102
+ // Fuzzy subsequence (greedy left-to-right). Whitespace in the query is
103
+ // skipped so "new pg" can still reach "New page". Adjacent hits merge into
104
+ // one range so the highlight reads as runs, not confetti.
105
+ const chars = q.replace(/\s+/g, '');
106
+ if (!chars) return null;
107
+ const ranges: MatchRange[] = [];
108
+ let from = 0;
109
+ for (let ci = 0; ci < chars.length; ci++) {
110
+ const at = t.indexOf(chars[ci]!, from);
111
+ if (at === -1) return null;
112
+ const last = ranges[ranges.length - 1];
113
+ if (last && last.end === at) last.end = at + 1;
114
+ else ranges.push({ start: at, end: at + 1 });
115
+ from = at + 1;
116
+ }
117
+ const firstHit = ranges[0]!.start;
118
+ const spread = ranges[ranges.length - 1]!.end - firstHit - chars.length;
119
+ const score = TIER_FUZZY - Math.min(spread * 8, 600) - Math.min(firstHit * 2, 200) - lengthPenalty(t.length, chars.length);
120
+ return { score, ranges };
121
+ }
122
+
123
+ export interface RankedResult<T> {
124
+ item: T;
125
+ score: number;
126
+ ranges: MatchRange[];
127
+ }
128
+
129
+ /**
130
+ * Rank a candidate list against a query: score every title, drop misses, sort
131
+ * by score DESC then `updatedAt` DESC (recency breaks ties), cap at `limit`.
132
+ */
133
+ export function rankResults<T extends { title: string; updatedAt: number }>(
134
+ query: string,
135
+ items: readonly T[],
136
+ limit = 50,
137
+ ): RankedResult<T>[] {
138
+ const out: RankedResult<T>[] = [];
139
+ for (const item of items) {
140
+ const m = matchTitle(query, item.title);
141
+ if (m) out.push({ item, score: m.score, ranges: m.ranges });
142
+ }
143
+ out.sort((a, b) => b.score - a.score || b.item.updatedAt - a.item.updatedAt);
144
+ return out.slice(0, limit);
145
+ }