@drakkar.software/octospaces-sdk 0.4.1 → 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.
- package/dist/index.d.ts +144 -1
- package/dist/index.js +164 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/types.ts +3 -2
- package/src/index.ts +20 -0
- package/src/utils/invite-preview.test.ts +169 -0
- package/src/utils/invite-preview.ts +101 -0
- package/src/utils/live-sync-bus.test.ts +116 -0
- package/src/utils/live-sync-bus.ts +71 -0
- package/src/utils/search-match.test.ts +149 -0
- package/src/utils/search-match.ts +145 -0
package/package.json
CHANGED
package/src/core/types.ts
CHANGED
|
@@ -21,8 +21,9 @@ export type ID = string;
|
|
|
21
21
|
/** A user's presence indicator. The theme maps each to a color (app-side). */
|
|
22
22
|
export type PresenceStatus = 'online' | 'away' | 'dnd' | 'offline';
|
|
23
23
|
|
|
24
|
-
/** A security item's verification state. The theme maps each to a color (app-side).
|
|
25
|
-
|
|
24
|
+
/** A security item's verification state. The theme maps each to a color (app-side).
|
|
25
|
+
* `none` = unknown / not yet verified; maps to a neutral/muted color in the theme. */
|
|
26
|
+
export type VerificationLevel = 'verified' | 'pending' | 'unverified' | 'none';
|
|
26
27
|
|
|
27
28
|
/** Maps a joined space's id → its owner-issued member cap-cert (serialized JSON).
|
|
28
29
|
* Persisted both in device-local kv and, for durability, in the user's own synced
|
package/src/index.ts
CHANGED
|
@@ -20,9 +20,13 @@ export type {
|
|
|
20
20
|
CapMap,
|
|
21
21
|
PubAccessMap,
|
|
22
22
|
DmMap,
|
|
23
|
+
MuteValue,
|
|
23
24
|
MutePrefs,
|
|
25
|
+
ReadValue,
|
|
24
26
|
ReadPrefs,
|
|
25
27
|
ArchivedDms,
|
|
28
|
+
PresenceStatus,
|
|
29
|
+
VerificationLevel,
|
|
26
30
|
} from './core/types.js';
|
|
27
31
|
|
|
28
32
|
// Ids
|
|
@@ -39,6 +43,7 @@ export {
|
|
|
39
43
|
keyringName,
|
|
40
44
|
keyringPull,
|
|
41
45
|
keyringPush,
|
|
46
|
+
objIndexName,
|
|
42
47
|
objIndexPull,
|
|
43
48
|
objIndexPush,
|
|
44
49
|
objPubName,
|
|
@@ -55,16 +60,22 @@ export {
|
|
|
55
60
|
spaceAccessPush,
|
|
56
61
|
profilePull,
|
|
57
62
|
profilePush,
|
|
63
|
+
objLogName,
|
|
58
64
|
objLogPull,
|
|
59
65
|
objLogPush,
|
|
66
|
+
objDocName,
|
|
60
67
|
objDocPull,
|
|
61
68
|
objDocPush,
|
|
69
|
+
objectBlobName,
|
|
62
70
|
objectBlobPull,
|
|
63
71
|
objectBlobPush,
|
|
72
|
+
typesIndexName,
|
|
64
73
|
typesIndexPull,
|
|
65
74
|
typesIndexPush,
|
|
75
|
+
attachmentName,
|
|
66
76
|
attachmentPull,
|
|
67
77
|
attachmentPush,
|
|
78
|
+
spaceIdFromRoomId,
|
|
68
79
|
userIdFromEdPub,
|
|
69
80
|
bytesToHex,
|
|
70
81
|
} from './sync/paths.js';
|
|
@@ -233,3 +244,12 @@ export { fetchWithTimeout, CONNECT_TIMEOUT_MS } from './sync/fetch-timeout.js';
|
|
|
233
244
|
// Base64
|
|
234
245
|
export { starfishBase64 } from './sync/base64.js';
|
|
235
246
|
export { toBase64Url, fromBase64Url } from './sync/base64url.js';
|
|
247
|
+
|
|
248
|
+
// Utilities
|
|
249
|
+
export { matchTitle, rankResults, fold, isWordStart } from './utils/search-match.js';
|
|
250
|
+
export type { MatchRange, TitleMatch, RankedResult } from './utils/search-match.js';
|
|
251
|
+
|
|
252
|
+
export { registerPull, dispatchDocChange, emitSseStatus, onSseStatus, clearLiveSyncBus } from './utils/live-sync-bus.js';
|
|
253
|
+
|
|
254
|
+
export { previewInvite } from './utils/invite-preview.js';
|
|
255
|
+
export type { InvitePreview } from './utils/invite-preview.js';
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { previewInvite } from './invite-preview.js';
|
|
3
|
+
import { encodeSpaceInviteLink } from '../spaces/members.js';
|
|
4
|
+
import { encodeNodeInviteLink } from '../spaces/nodes.js';
|
|
5
|
+
import type { SpaceInviteLinkToken } from '../spaces/members.js';
|
|
6
|
+
import type { NodeInviteLinkToken } from '../spaces/nodes.js';
|
|
7
|
+
|
|
8
|
+
const spaceToken: SpaceInviteLinkToken = {
|
|
9
|
+
v: 1,
|
|
10
|
+
spaceId: 'sp-abc123',
|
|
11
|
+
spaceName: 'My Space',
|
|
12
|
+
cap: { kind: 'member', iss: 'deadbeef', sub: 'cafecafe', scope: {} },
|
|
13
|
+
key: 'a1b2c3d4',
|
|
14
|
+
write: true,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const nodeToken: NodeInviteLinkToken = {
|
|
18
|
+
v: 1,
|
|
19
|
+
spaceId: 'sp-abc123',
|
|
20
|
+
nodeId: 'nd-xyz789',
|
|
21
|
+
nodeName: 'Secret Doc',
|
|
22
|
+
cap: { kind: 'member', iss: 'deadbeef', sub: 'cafecafe', scope: {} },
|
|
23
|
+
key: 'b2c3d4e5',
|
|
24
|
+
write: false,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const spaceLink = encodeSpaceInviteLink('https://app.example.com', spaceToken);
|
|
28
|
+
const nodeLink = encodeNodeInviteLink('https://app.example.com', nodeToken);
|
|
29
|
+
|
|
30
|
+
// ── space-link ────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe('previewInvite — space-link', () => {
|
|
33
|
+
it('classifies a space invite link', () => {
|
|
34
|
+
const p = previewInvite(spaceLink);
|
|
35
|
+
expect(p.kind).toBe('space-link');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('extracts spaceName and write flag', () => {
|
|
39
|
+
const p = previewInvite(spaceLink);
|
|
40
|
+
if (p.kind !== 'space-link') throw new Error('wrong kind');
|
|
41
|
+
expect(p.spaceName).toBe('My Space');
|
|
42
|
+
expect(p.write).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('exposes the decoded token', () => {
|
|
46
|
+
const p = previewInvite(spaceLink);
|
|
47
|
+
if (p.kind !== 'space-link') throw new Error('wrong kind');
|
|
48
|
+
expect(p.token.spaceId).toBe('sp-abc123');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('accepts a raw fragment (no origin prefix)', () => {
|
|
52
|
+
const fragment = spaceLink.slice(spaceLink.indexOf('#'));
|
|
53
|
+
const p = previewInvite(fragment);
|
|
54
|
+
expect(p.kind).toBe('space-link');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('accepts a read-only (write:false) link', () => {
|
|
58
|
+
const readOnlyToken: SpaceInviteLinkToken = { ...spaceToken, write: false };
|
|
59
|
+
const link = encodeSpaceInviteLink('https://app.example.com', readOnlyToken);
|
|
60
|
+
const p = previewInvite(link);
|
|
61
|
+
if (p.kind !== 'space-link') throw new Error('wrong kind');
|
|
62
|
+
expect(p.write).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── node-link ─────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe('previewInvite — node-link', () => {
|
|
69
|
+
it('classifies a node invite link', () => {
|
|
70
|
+
const p = previewInvite(nodeLink);
|
|
71
|
+
expect(p.kind).toBe('node-link');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('extracts nodeTitle from nodeName', () => {
|
|
75
|
+
const p = previewInvite(nodeLink);
|
|
76
|
+
if (p.kind !== 'node-link') throw new Error('wrong kind');
|
|
77
|
+
expect(p.nodeTitle).toBe('Secret Doc');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('exposes spaceId via spaceName fallback', () => {
|
|
81
|
+
const p = previewInvite(nodeLink);
|
|
82
|
+
if (p.kind !== 'node-link') throw new Error('wrong kind');
|
|
83
|
+
expect(p.spaceName).toContain('abc123'.slice(-6));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('exposes the decoded token', () => {
|
|
87
|
+
const p = previewInvite(nodeLink);
|
|
88
|
+
if (p.kind !== 'node-link') throw new Error('wrong kind');
|
|
89
|
+
expect(p.token.nodeId).toBe('nd-xyz789');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── member-bundle ─────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
const bundle = JSON.stringify({
|
|
96
|
+
spaceId: 'sp-abc123',
|
|
97
|
+
spaceName: 'Team Space',
|
|
98
|
+
cap: { kind: 'member', iss: 'aabbccddee112233' },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('previewInvite — member-bundle', () => {
|
|
102
|
+
it('classifies a private member-bundle JSON', () => {
|
|
103
|
+
const p = previewInvite(bundle);
|
|
104
|
+
expect(p.kind).toBe('member-bundle');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('extracts spaceName and spaceId', () => {
|
|
108
|
+
const p = previewInvite(bundle);
|
|
109
|
+
if (p.kind !== 'member-bundle') throw new Error('wrong kind');
|
|
110
|
+
expect(p.spaceName).toBe('Team Space');
|
|
111
|
+
expect(p.spaceId).toBe('sp-abc123');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('builds issuerKey fingerprint from iss', () => {
|
|
115
|
+
const p = previewInvite(bundle);
|
|
116
|
+
if (p.kind !== 'member-bundle') throw new Error('wrong kind');
|
|
117
|
+
expect(p.issuerKey).toContain('aabbccdd');
|
|
118
|
+
expect(p.issuerKey).toContain('…');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('issuerKey is null when iss is absent', () => {
|
|
122
|
+
const noIss = JSON.stringify({ spaceId: 'sp-abc123', cap: { kind: 'member' } });
|
|
123
|
+
const p = previewInvite(noIss);
|
|
124
|
+
if (p.kind !== 'member-bundle') throw new Error('wrong kind');
|
|
125
|
+
expect(p.issuerKey).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('falls back to id-derived spaceName when spaceName is absent', () => {
|
|
129
|
+
const noName = JSON.stringify({ spaceId: 'sp-abc123', cap: { kind: 'member' } });
|
|
130
|
+
const p = previewInvite(noName);
|
|
131
|
+
if (p.kind !== 'member-bundle') throw new Error('wrong kind');
|
|
132
|
+
expect(p.spaceName).toBe('space-abc123');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('preserves raw inviteJson verbatim', () => {
|
|
136
|
+
const p = previewInvite(bundle);
|
|
137
|
+
if (p.kind !== 'member-bundle') throw new Error('wrong kind');
|
|
138
|
+
expect(p.inviteJson).toBe(bundle);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── error cases ───────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
describe('previewInvite — errors', () => {
|
|
145
|
+
it('throws on empty input', () => {
|
|
146
|
+
expect(() => previewInvite('')).toThrow('Paste an invite');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('throws on a whitespace-only input', () => {
|
|
150
|
+
expect(() => previewInvite(' ')).toThrow('Paste an invite');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('throws on a malformed URL fragment', () => {
|
|
154
|
+
expect(() => previewInvite('#not-valid-base64!!!!')).toThrow();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('throws on plain text (not JSON, not a link)', () => {
|
|
158
|
+
expect(() => previewInvite('hello world')).toThrow("doesn't look like an invite");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('throws on JSON that is not a member bundle', () => {
|
|
162
|
+
expect(() => previewInvite(JSON.stringify({ foo: 'bar' }))).toThrow('not a valid space invite');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('throws on a bundle whose cap.kind is not member', () => {
|
|
166
|
+
const bad = JSON.stringify({ spaceId: 'sp-1', cap: { kind: 'owner' } });
|
|
167
|
+
expect(() => previewInvite(bad)).toThrow('not a valid space invite');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse an invite (pasted text or a `#fragment` deep link) into a preview the
|
|
3
|
+
* join screen can show on a consent card — name, type, identifying fingerprint
|
|
4
|
+
* — WITHOUT joining. Actual join calls (`joinSpaceByLink`, `joinNodeByLink`,
|
|
5
|
+
* `acceptSpaceInvite`, `acceptNodeInvite`) only run after the user confirms.
|
|
6
|
+
*
|
|
7
|
+
* Accepts three input forms:
|
|
8
|
+
* - A space invite link (URL fragment encoded by `createSpaceInviteLink`)
|
|
9
|
+
* - A node invite link (URL fragment encoded by `createNodeInviteLink`)
|
|
10
|
+
* - A private member-bundle JSON minted by `inviteToSpace`
|
|
11
|
+
*/
|
|
12
|
+
import { decodeSpaceInviteLink } from '../spaces/members.js';
|
|
13
|
+
import { decodeNodeInviteLink } from '../spaces/nodes.js';
|
|
14
|
+
import type { SpaceInviteLinkToken } from '../spaces/members.js';
|
|
15
|
+
import type { NodeInviteLinkToken } from '../spaces/nodes.js';
|
|
16
|
+
|
|
17
|
+
export type { SpaceInviteLinkToken, NodeInviteLinkToken };
|
|
18
|
+
|
|
19
|
+
export type InvitePreview =
|
|
20
|
+
| {
|
|
21
|
+
kind: 'space-link';
|
|
22
|
+
spaceName: string;
|
|
23
|
+
/** True if the link grants write access, false for read-only. */
|
|
24
|
+
write: boolean;
|
|
25
|
+
token: SpaceInviteLinkToken;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
kind: 'node-link';
|
|
29
|
+
spaceName: string;
|
|
30
|
+
/** The node's display name, absent for legacy tokens that omit it. */
|
|
31
|
+
nodeTitle?: string;
|
|
32
|
+
token: NodeInviteLinkToken;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
kind: 'member-bundle';
|
|
36
|
+
spaceName: string;
|
|
37
|
+
spaceId: string;
|
|
38
|
+
/** Short hex fingerprint of the issuing owner's signing key, or null if absent. */
|
|
39
|
+
issuerKey: string | null;
|
|
40
|
+
/** The raw cap-bundle JSON — pass verbatim to `acceptSpaceInvite` on consent. */
|
|
41
|
+
inviteJson: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Shape of the private invite bundle minted by `inviteToSpace`. */
|
|
45
|
+
interface PrivateInviteShape {
|
|
46
|
+
spaceId?: string;
|
|
47
|
+
spaceName?: string;
|
|
48
|
+
cap?: { kind?: string; iss?: string };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Classify and decode an invite string into a typed {@link InvitePreview}.
|
|
53
|
+
* Throws a human-readable `Error` on invalid input (safe to surface verbatim
|
|
54
|
+
* in a toast or inline error message).
|
|
55
|
+
*/
|
|
56
|
+
export function previewInvite(raw: string): InvitePreview {
|
|
57
|
+
const text = raw.trim();
|
|
58
|
+
if (!text) throw new Error('Paste an invite link or code first.');
|
|
59
|
+
|
|
60
|
+
// Invite links carry their credential in a URL fragment.
|
|
61
|
+
if (text.includes('#')) {
|
|
62
|
+
const fragment = text.slice(text.indexOf('#'));
|
|
63
|
+
// Try node-invite link first — it has a `nodeId` field the space link lacks.
|
|
64
|
+
try {
|
|
65
|
+
const token = decodeNodeInviteLink(fragment);
|
|
66
|
+
return {
|
|
67
|
+
kind: 'node-link',
|
|
68
|
+
spaceName: `space-${token.spaceId.slice(-6)}`,
|
|
69
|
+
nodeTitle: token.nodeName,
|
|
70
|
+
token,
|
|
71
|
+
};
|
|
72
|
+
} catch {
|
|
73
|
+
// fall through to space link
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const token = decodeSpaceInviteLink(fragment);
|
|
77
|
+
return { kind: 'space-link', spaceName: token.spaceName, write: token.write, token };
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error('That invite link appears to be invalid or expired.');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Private member-bundle JSON minted by `inviteToSpace`.
|
|
84
|
+
let parsed: PrivateInviteShape;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(text) as PrivateInviteShape;
|
|
87
|
+
} catch {
|
|
88
|
+
throw new Error("That doesn't look like an invite. Paste the full invite code or link.");
|
|
89
|
+
}
|
|
90
|
+
if (!parsed?.spaceId || parsed.cap?.kind !== 'member') {
|
|
91
|
+
throw new Error('That is not a valid space invite.');
|
|
92
|
+
}
|
|
93
|
+
const iss = parsed.cap?.iss;
|
|
94
|
+
return {
|
|
95
|
+
kind: 'member-bundle',
|
|
96
|
+
spaceName: parsed.spaceName?.trim() || `space-${parsed.spaceId.slice(-6)}`,
|
|
97
|
+
spaceId: parsed.spaceId,
|
|
98
|
+
issuerKey: typeof iss === 'string' && iss.length >= 8 ? `${iss.slice(0, 8)}…${iss.slice(-8)}` : null,
|
|
99
|
+
inviteJson: text,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
registerPull,
|
|
4
|
+
dispatchDocChange,
|
|
5
|
+
emitSseStatus,
|
|
6
|
+
onSseStatus,
|
|
7
|
+
clearLiveSyncBus,
|
|
8
|
+
} from './live-sync-bus.js';
|
|
9
|
+
|
|
10
|
+
beforeEach(() => clearLiveSyncBus());
|
|
11
|
+
|
|
12
|
+
// ── registerPull / dispatchDocChange ──────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe('registerPull / dispatchDocChange', () => {
|
|
15
|
+
it('returns false when no pull is registered for the path', () => {
|
|
16
|
+
expect(dispatchDocChange('spaces/sp-1/objects/_index')).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('calls the registered pull and returns true', () => {
|
|
20
|
+
const pull = vi.fn();
|
|
21
|
+
registerPull('spaces/sp-1/objects/_index', pull);
|
|
22
|
+
const dispatched = dispatchDocChange('spaces/sp-1/objects/_index');
|
|
23
|
+
expect(dispatched).toBe(true);
|
|
24
|
+
expect(pull).toHaveBeenCalledOnce();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('does not cross-fire — different paths are independent', () => {
|
|
28
|
+
const pull1 = vi.fn();
|
|
29
|
+
const pull2 = vi.fn();
|
|
30
|
+
registerPull('spaces/sp-1/objects/_index', pull1);
|
|
31
|
+
registerPull('spaces/sp-2/objects/_index', pull2);
|
|
32
|
+
dispatchDocChange('spaces/sp-1/objects/_index');
|
|
33
|
+
expect(pull1).toHaveBeenCalledOnce();
|
|
34
|
+
expect(pull2).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('unsubscribe removes the registration', () => {
|
|
38
|
+
const pull = vi.fn();
|
|
39
|
+
const unsub = registerPull('spaces/sp-1/objects/_index', pull);
|
|
40
|
+
unsub();
|
|
41
|
+
expect(dispatchDocChange('spaces/sp-1/objects/_index')).toBe(false);
|
|
42
|
+
expect(pull).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('stale unsubscribe is a no-op after re-registration', () => {
|
|
46
|
+
const pull1 = vi.fn();
|
|
47
|
+
const pull2 = vi.fn();
|
|
48
|
+
const unsub1 = registerPull('spaces/sp-1/objects/_index', pull1);
|
|
49
|
+
registerPull('spaces/sp-1/objects/_index', pull2); // overwrites
|
|
50
|
+
unsub1(); // should NOT remove pull2
|
|
51
|
+
dispatchDocChange('spaces/sp-1/objects/_index');
|
|
52
|
+
expect(pull2).toHaveBeenCalledOnce();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── emitSseStatus / onSseStatus ───────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe('emitSseStatus / onSseStatus', () => {
|
|
59
|
+
it('fires immediately with the current state (false by default)', () => {
|
|
60
|
+
const cb = vi.fn();
|
|
61
|
+
onSseStatus(cb);
|
|
62
|
+
expect(cb).toHaveBeenCalledWith(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('fires on each status change', () => {
|
|
66
|
+
const cb = vi.fn();
|
|
67
|
+
onSseStatus(cb);
|
|
68
|
+
emitSseStatus(true);
|
|
69
|
+
emitSseStatus(false);
|
|
70
|
+
expect(cb).toHaveBeenNthCalledWith(1, false); // initial
|
|
71
|
+
expect(cb).toHaveBeenNthCalledWith(2, true);
|
|
72
|
+
expect(cb).toHaveBeenNthCalledWith(3, false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('unsubscribe stops receiving updates', () => {
|
|
76
|
+
const cb = vi.fn();
|
|
77
|
+
const unsub = onSseStatus(cb);
|
|
78
|
+
unsub();
|
|
79
|
+
emitSseStatus(true);
|
|
80
|
+
expect(cb).toHaveBeenCalledOnce(); // only the initial fire
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('new subscriber gets current state after emitSseStatus(true)', () => {
|
|
84
|
+
emitSseStatus(true);
|
|
85
|
+
const cb = vi.fn();
|
|
86
|
+
onSseStatus(cb);
|
|
87
|
+
expect(cb).toHaveBeenCalledWith(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── clearLiveSyncBus ──────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe('clearLiveSyncBus', () => {
|
|
94
|
+
it('clears all registered pulls', () => {
|
|
95
|
+
const pull = vi.fn();
|
|
96
|
+
registerPull('spaces/sp-1/objects/_index', pull);
|
|
97
|
+
clearLiveSyncBus();
|
|
98
|
+
expect(dispatchDocChange('spaces/sp-1/objects/_index')).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('resets SSE health to false', () => {
|
|
102
|
+
emitSseStatus(true);
|
|
103
|
+
clearLiveSyncBus();
|
|
104
|
+
const cb = vi.fn();
|
|
105
|
+
onSseStatus(cb);
|
|
106
|
+
expect(cb).toHaveBeenCalledWith(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('leaves status listeners intact (they self-unsub on unmount)', () => {
|
|
110
|
+
const cb = vi.fn();
|
|
111
|
+
onSseStatus(cb);
|
|
112
|
+
clearLiveSyncBus();
|
|
113
|
+
emitSseStatus(true);
|
|
114
|
+
expect(cb).toHaveBeenCalledWith(true); // still fires
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -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
|
+
});
|