Skip to content

Commit f21c275

Browse files
robelestclaude
andcommitted
feat(replicate): add CRDT bindings for counter, register, and set types
Implement full CRDT binding system with dedicated handlers for each type: - schema.counter(): Sum-based counter that never loses concurrent increments - schema.register(): Multi-value register with custom conflict resolution - schema.set(): Add-wins set where concurrent adds are unioned Key changes: - Add bindings/ module with CounterBinding, RegisterBinding, SetBinding - Add collection.utils.counter(), .register(), .set() methods - Add schema.counter(), .register(), .set() server helpers - Update IntervalEditor to use proper CRDT bindings for tags, status, priority - CRDT fields are now intentionally skipped by collection.update() Breaking: CRDT fields (counter, register, set, prose) must use their dedicated bindings - direct updates via collection.update() are ignored. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 83960d6 commit f21c275

22 files changed

Lines changed: 2532 additions & 265 deletions

File tree

apps/sveltekit/src/lib/components/IntervalEditor.svelte

Lines changed: 198 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,24 @@
1616
<script lang="ts">
1717
import { browser } from '$app/environment';
1818
import type { Editor } from '@tiptap/core';
19-
import type { EditorBinding } from '@trestleinc/replicate/client';
19+
import type { EditorBinding, CounterBinding, SetBinding, RegisterBinding } from '@trestleinc/replicate/client';
2020
import { getIntervalsContext } from '$lib/contexts/intervals.svelte';
2121
import type { Interval } from '$collections/useIntervals';
2222
import StatusIcon from './StatusIcon.svelte';
2323
import PriorityIcon from './PriorityIcon.svelte';
2424
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
2525
import * as Avatar from '$lib/components/ui/avatar';
2626
import * as Tooltip from '$lib/components/ui/tooltip';
27-
import { Globe, Lock } from '@lucide/svelte';
27+
import { Globe, Lock, X } from '@lucide/svelte';
2828
import { getAuthClient } from '$lib/auth-client';
2929
3030
interface Props {
3131
intervalId: string;
3232
interval: Interval;
33-
onPropertyUpdate?: (updates: Partial<Pick<Interval, 'status' | 'priority'>>) => void;
33+
showProperties?: boolean;
3434
}
3535
36-
let { intervalId, interval, onPropertyUpdate }: Props = $props();
36+
let { intervalId, interval, showProperties = false }: Props = $props();
3737
3838
// Auth state for ownership check
3939
let sessionData = $state<{ user?: { id: string } } | null>(null);
@@ -59,6 +59,8 @@
5959
let editorElement = $state<HTMLDivElement | null>(null);
6060
let editor = $state<Editor | null>(null);
6161
let binding = $state<EditorBinding | null>(null);
62+
let counter = $state<CounterBinding | null>(null);
63+
let viewCount = $state<number>(0);
6264
let error = $state<string | null>(null);
6365
let isLoading = $state(true);
6466
@@ -75,6 +77,17 @@
7577
let editingTitle = $state('');
7678
let titleInputRef = $state<HTMLInputElement | null>(null);
7779
80+
// Tags state
81+
let newTagInput = $state('');
82+
let tagsBinding = $state<SetBinding<string> | null>(null);
83+
let tags = $state<string[]>([]);
84+
85+
// Status and Priority bindings (CRDT registers)
86+
let statusBinding = $state<RegisterBinding<StatusValue> | null>(null);
87+
let priorityBinding = $state<RegisterBinding<PriorityValue> | null>(null);
88+
let status = $state<StatusValue>(interval.status as StatusValue);
89+
let priority = $state<PriorityValue>(interval.priority as PriorityValue);
90+
7891
const title = $derived(isEditingTitle ? editingTitle : interval.title);
7992
8093
$effect(() => {
@@ -83,6 +96,26 @@
8396
}
8497
});
8598
99+
100+
function addTag(tag: string) {
101+
const normalized = tag.trim().toLowerCase();
102+
if (!normalized || tags.includes(normalized)) return;
103+
104+
tagsBinding?.add(normalized);
105+
newTagInput = '';
106+
}
107+
108+
function removeTag(tag: string) {
109+
tagsBinding?.remove(tag);
110+
}
111+
112+
function handleTagKeyDown(e: KeyboardEvent) {
113+
if (e.key === 'Enter') {
114+
e.preventDefault();
115+
addTag(newTagInput);
116+
}
117+
}
118+
86119
// Subscribe to awareness changes for remote user avatars
87120
$effect(() => {
88121
const awareness = binding?.provider?.awareness;
@@ -165,11 +198,13 @@
165198
166199
return () => {
167200
binding?.destroy();
201+
counter?.destroy();
168202
if (editor) {
169203
editor.destroy();
170204
editor = null;
171205
}
172206
binding = null;
207+
counter = null;
173208
};
174209
});
175210
@@ -197,6 +232,113 @@
197232
initBinding();
198233
});
199234
235+
// Initialize counter binding for page view tracking
236+
$effect(() => {
237+
if (!browser) return;
238+
239+
const initCounter = async () => {
240+
try {
241+
counter = await intervalsCtx.collection.utils.counter(intervalId, 'viewCount');
242+
// Auto-increment view count on page load
243+
counter.increment(1);
244+
// Subscribe to view count changes
245+
counter.subscribe((value) => {
246+
viewCount = value;
247+
});
248+
} catch (err) {
249+
// Counter errors are non-fatal, just log them
250+
console.error('Failed to initialize view counter:', err);
251+
}
252+
};
253+
254+
initCounter();
255+
256+
return () => {
257+
counter?.destroy();
258+
};
259+
});
260+
261+
// Initialize tags binding for CRDT set
262+
$effect(() => {
263+
if (!browser) return;
264+
265+
const initTags = async () => {
266+
try {
267+
tagsBinding = await intervalsCtx.collection.utils.set<string>(intervalId, 'tags', {
268+
serialize: (item) => item,
269+
deserialize: (key) => key,
270+
});
271+
// Subscribe to tag changes
272+
tagsBinding.subscribe((values) => {
273+
tags = values;
274+
});
275+
} catch (err) {
276+
console.error('Failed to initialize tags binding:', err);
277+
}
278+
};
279+
280+
initTags();
281+
282+
return () => {
283+
tagsBinding?.destroy();
284+
tagsBinding = null;
285+
};
286+
});
287+
288+
// Initialize status binding for CRDT register
289+
$effect(() => {
290+
if (!browser) return;
291+
292+
const initStatus = async () => {
293+
try {
294+
statusBinding = await intervalsCtx.collection.utils.register<StatusValue>(
295+
intervalId,
296+
'status'
297+
);
298+
// Subscribe to status changes
299+
statusBinding.subscribe((value) => {
300+
status = value;
301+
});
302+
} catch (err) {
303+
console.error('Failed to initialize status binding:', err);
304+
}
305+
};
306+
307+
initStatus();
308+
309+
return () => {
310+
statusBinding?.destroy();
311+
statusBinding = null;
312+
};
313+
});
314+
315+
// Initialize priority binding for CRDT register
316+
$effect(() => {
317+
if (!browser) return;
318+
319+
const initPriority = async () => {
320+
try {
321+
priorityBinding = await intervalsCtx.collection.utils.register<PriorityValue>(
322+
intervalId,
323+
'priority'
324+
);
325+
// Subscribe to priority changes
326+
priorityBinding.subscribe((value) => {
327+
priority = value;
328+
});
329+
} catch (err) {
330+
console.error('Failed to initialize priority binding:', err);
331+
}
332+
};
333+
334+
initPriority();
335+
336+
return () => {
337+
priorityBinding?.destroy();
338+
priorityBinding = null;
339+
};
340+
});
341+
200342
function startEditing() {
201343
editingTitle = interval.title;
202344
isEditingTitle = true;
@@ -255,7 +397,7 @@
255397
</button>
256398
{/if}
257399

258-
{#if onPropertyUpdate}
400+
{#if showProperties}
259401
<div
260402
class="border-border mt-4 mb-6 flex items-center justify-between gap-4 border-b pb-4 text-sm"
261403
>
@@ -264,18 +406,18 @@
264406
<DropdownMenu.Trigger
265407
class="hover:bg-muted transition-fast flex items-center gap-2 px-2 py-1"
266408
>
267-
<StatusIcon status={interval.status as StatusValue} size={14} />
268-
<span class="text-sm">{StatusLabels[interval.status as StatusValue]}</span>
409+
<StatusIcon {status} size={14} />
410+
<span class="text-sm">{StatusLabels[status]}</span>
269411
</DropdownMenu.Trigger>
270412
<DropdownMenu.Content align="start">
271413
<DropdownMenu.RadioGroup
272-
value={interval.status}
273-
onValueChange={(v) => onPropertyUpdate({ status: v as StatusValue })}
414+
value={status}
415+
onValueChange={(v) => statusBinding?.set(v as StatusValue)}
274416
>
275-
{#each statusOptions as status (status)}
276-
<DropdownMenu.RadioItem value={status}>
277-
<StatusIcon {status} size={14} />
278-
<span class="ml-2">{StatusLabels[status]}</span>
417+
{#each statusOptions as statusOption (statusOption)}
418+
<DropdownMenu.RadioItem value={statusOption}>
419+
<StatusIcon status={statusOption} size={14} />
420+
<span class="ml-2">{StatusLabels[statusOption]}</span>
279421
</DropdownMenu.RadioItem>
280422
{/each}
281423
</DropdownMenu.RadioGroup>
@@ -286,18 +428,18 @@
286428
<DropdownMenu.Trigger
287429
class="hover:bg-muted transition-fast flex items-center gap-2 px-2 py-1"
288430
>
289-
<PriorityIcon priority={interval.priority as PriorityValue} size={14} />
290-
<span class="text-sm">{PriorityLabels[interval.priority as PriorityValue]}</span>
431+
<PriorityIcon {priority} size={14} />
432+
<span class="text-sm">{PriorityLabels[priority]}</span>
291433
</DropdownMenu.Trigger>
292434
<DropdownMenu.Content align="start">
293435
<DropdownMenu.RadioGroup
294-
value={interval.priority}
295-
onValueChange={(v) => onPropertyUpdate({ priority: v as PriorityValue })}
436+
value={priority}
437+
onValueChange={(v) => priorityBinding?.set(v as PriorityValue)}
296438
>
297-
{#each priorityOptions as priority (priority)}
298-
<DropdownMenu.RadioItem value={priority}>
299-
<PriorityIcon {priority} size={14} />
300-
<span class="ml-2">{PriorityLabels[priority]}</span>
439+
{#each priorityOptions as priorityOption (priorityOption)}
440+
<DropdownMenu.RadioItem value={priorityOption}>
441+
<PriorityIcon priority={priorityOption} size={14} />
442+
<span class="ml-2">{PriorityLabels[priorityOption]}</span>
301443
</DropdownMenu.RadioItem>
302444
{/each}
303445
</DropdownMenu.RadioGroup>
@@ -325,8 +467,43 @@
325467
{/if}
326468
</button>
327469
{/if}
470+
471+
<!-- Tags Section -->
472+
<div class="bg-border mx-2 h-4 w-px"></div>
473+
474+
<div class="flex items-center gap-1.5">
475+
{#each tags as tag (tag)}
476+
<span
477+
class="bg-secondary text-secondary-foreground flex items-center gap-1 px-2 py-0.5 text-xs font-medium"
478+
>
479+
{tag}
480+
<button
481+
type="button"
482+
class="hover:text-destructive -mr-0.5 p-0.5 transition-fast"
483+
onclick={() => removeTag(tag)}
484+
aria-label="Remove {tag} tag"
485+
>
486+
<X class="h-3 w-3" />
487+
</button>
488+
</span>
489+
{/each}
490+
491+
<input
492+
type="text"
493+
bind:value={newTagInput}
494+
onkeydown={handleTagKeyDown}
495+
placeholder="+ tag"
496+
class="bg-transparent text-muted-foreground placeholder:text-muted-foreground/50 h-6 w-16 px-1 text-xs outline-none transition-fast focus:w-24"
497+
/>
498+
</div>
328499
</div>
329500

501+
{#if viewCount > 0}
502+
<div class="text-muted-foreground text-sm" aria-label="View count">
503+
{viewCount} view{viewCount === 1 ? '' : 's'}
504+
</div>
505+
{/if}
506+
330507
{#if remoteUsers.length > 0}
331508
<Tooltip.Provider>
332509
<div class="flex gap-1.5" aria-label="Active collaborators">

apps/sveltekit/src/routes/intervals/[id]/+page.svelte

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,6 @@
3434
3535
// True "not found" only when: not loading and no interval (current or cached)
3636
const notFound = $derived(!intervalsCtx.isLoading && interval === null);
37-
38-
function handlePropertyUpdate(updates: Partial<Pick<Interval, 'status' | 'priority'>>) {
39-
if (interval) {
40-
intervalsCtx.collection.update(interval.id, (draft) => {
41-
if (updates.status !== undefined) draft.status = updates.status;
42-
if (updates.priority !== undefined) draft.priority = updates.priority;
43-
draft.updatedAt = Date.now();
44-
});
45-
}
46-
}
4737
</script>
4838

4939
{#if intervalsCtx.isLoading && !interval}
@@ -58,7 +48,7 @@
5848
{:else if interval && id}
5949
<div class="flex-1 overflow-auto">
6050
{#key id}
61-
<IntervalEditor intervalId={id} {interval} onPropertyUpdate={handlePropertyUpdate} />
51+
<IntervalEditor intervalId={id} {interval} showProperties />
6252
{/key}
6353
<CommentList intervalId={id} isPublic={interval.isPublic} />
6454
</div>

0 commit comments

Comments
 (0)