Skip to content

Commit ce067b9

Browse files
committed
Live queries / real-time queries #1174
1 parent 74e9585 commit ce067b9

File tree

17 files changed

+601
-118
lines changed

17 files changed

+601
-118
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ See [STATUS.md](server/STATUS.md) to learn more about which features will remain
1010
- [#1139](https://github.com/ontola/atomic-server/issues/1139) AtomicServer can now create data without being dependent on a server! AtomicServer is now Local-First, using the new `did:ad` schema. Instead of relying on HTTP, Atomic can resolve resources over DHT Mainline. It combines true decentralization, cryptographic proof of ownership and high performance. User's agents are now also truly decentralized, relying solely on a private key.
1111
- #584 Replace ureq with reqwest (async HTTP calls)
1212
- #481 Drive scoped queries
13-
- #1164 #1166 New Agents get private drives, shared resources through invites listed there
13+
- #1174 Live queries / real-time queries
14+
- #1164 #1166 New Agents get private drives, shared resources through invites listed there
1415
- #420 Fix OTLP / OpenTelemetry, update docs from Jaeger to SigNoz, add metrics
1516
- [#590](https://github.com/ontola/atomic-server/issues/590) Get rid of the `SERVER_URL` env var, which makes moving & setup easier. All resources are now relative to the hosted domain, and AtomicServer can be available from multiple domains at once.
1617
- [#544](https://github.com/ontola/atomicdata-dev/atomic-server/issues/544) Stateless invites, using JWTs. Server setup now requires you to check the logs for the invite token.

browser/data-browser/src/chunks/RTE/useLoroSync.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ export function useLoroSync(
1919
return new CursorEphemeralStore(doc.peerIdStr, 30000);
2020
}, [doc]);
2121

22-
// Subscribe to local doc updates and broadcast them
22+
// Subscribe to local doc updates, broadcast them, and mark resource dirty
2323
useEffect(() => {
2424
const unsub = doc.subscribeLocalUpdates((update: Uint8Array) => {
2525
store.broadcastLoroSyncUpdate(subject, update);
26+
// Mark the resource as dirty so save() knows there are local changes
27+
resource.markDirty();
2628
});
2729

2830
return () => {

browser/data-browser/src/components/Share/ShareDialog.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import React, { cloneElement, isValidElement, useState, type JSX } from 'react';
2-
import { useCanWrite, useResource } from '@tomic/react';
2+
import { useCanWrite, useResource, useStore } from '@tomic/react';
33
import { Dialog, useDialog } from '../Dialog';
44
import { Button } from '../Button';
55
import { InviteForm } from '../InviteForm';
66
import toast from 'react-hot-toast';
77
import { Title } from '../Title';
88
import { ErrorLook } from '../ErrorLook';
99
import { Column } from '../Row';
10-
import { FaShare } from 'react-icons/fa6';
10+
import { FaLink, FaShare } from 'react-icons/fa6';
1111
import { useRights } from '../../routes/Share/useRights';
1212
import { AgentRights } from '../../routes/Share/AgentRights';
1313
import { useInheritedRights } from '../../routes/Share/useInheritedRights';
@@ -69,6 +69,7 @@ export function ShareDialog({
6969
</Dialog.Title>
7070
<Dialog.Content>
7171
<Column gap='1rem'>
72+
<CopyLinkButton subject={subject} />
7273
{canWrite && !showInviteForm && (
7374
<Button onClick={() => setShowInviteForm(true)}>
7475
<FaShare />
@@ -142,6 +143,33 @@ const RightsCard = styled.div`
142143
border-radius: ${p => p.theme.radius};
143144
`;
144145

146+
function CopyLinkButton({ subject }: { subject: string }): JSX.Element {
147+
const store = useStore();
148+
149+
const handleCopy = () => {
150+
// Build a full URL that can be opened in a browser.
151+
// For DID subjects, use the server URL + DID path.
152+
let link: string;
153+
154+
if (subject.startsWith('did:')) {
155+
const server = store.getServerUrl().replace(/\/$/, '');
156+
link = `${server}/${subject}`;
157+
} else {
158+
link = subject;
159+
}
160+
161+
navigator.clipboard.writeText(link);
162+
toast.success('Link copied to clipboard');
163+
};
164+
165+
return (
166+
<Button subtle onClick={handleCopy}>
167+
<FaLink />
168+
Copy link
169+
</Button>
170+
);
171+
}
172+
145173
function RightsHeader({ children }: React.PropsWithChildren): JSX.Element {
146174
return (
147175
<PermissionRow>

browser/data-browser/src/locales/de.po

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4736,3 +4736,11 @@ msgstr ""
47364736
#: src/routes/History/VersionTitle.tsx
47374737
msgid "Edited <0/> {0} {1}"
47384738
msgstr ""
4739+
4740+
#: src/components/Share/ShareDialog.tsx
4741+
msgid "Link copied to clipboard"
4742+
msgstr ""
4743+
4744+
#: src/components/Share/ShareDialog.tsx
4745+
msgid "<0/> Copy link"
4746+
msgstr ""

browser/data-browser/src/locales/en.po

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4696,3 +4696,11 @@ msgstr "by peer {0}..."
46964696
#: src/routes/History/VersionTitle.tsx
46974697
msgid "Edited <0/> {0} {1}"
46984698
msgstr "Edited <0/> {0} {1}"
4699+
4700+
#: src/components/Share/ShareDialog.tsx
4701+
msgid "Link copied to clipboard"
4702+
msgstr "Link copied to clipboard"
4703+
4704+
#: src/components/Share/ShareDialog.tsx
4705+
msgid "<0/> Copy link"
4706+
msgstr "<0/> Copy link"

browser/data-browser/src/locales/es.po

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4722,3 +4722,11 @@ msgstr ""
47224722
#: src/routes/History/VersionTitle.tsx
47234723
msgid "Edited <0/> {0} {1}"
47244724
msgstr ""
4725+
4726+
#: src/components/Share/ShareDialog.tsx
4727+
msgid "Link copied to clipboard"
4728+
msgstr ""
4729+
4730+
#: src/components/Share/ShareDialog.tsx
4731+
msgid "<0/> Copy link"
4732+
msgstr ""

browser/data-browser/src/locales/fr.po

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4732,3 +4732,11 @@ msgstr ""
47324732
#: src/routes/History/VersionTitle.tsx
47334733
msgid "Edited <0/> {0} {1}"
47344734
msgstr ""
4735+
4736+
#: src/components/Share/ShareDialog.tsx
4737+
msgid "Link copied to clipboard"
4738+
msgstr ""
4739+
4740+
#: src/components/Share/ShareDialog.tsx
4741+
msgid "<0/> Copy link"
4742+
msgstr ""

browser/lib/src/commit.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -610,14 +610,17 @@ function execLoroUpdateCommit(
610610
// Store the raw binary for round-tripping
611611
resource.setUnsafe(commits.properties.loroUpdate, loroUpdate);
612612

613-
// Import into the resource's LoroDoc and materialize properties
614-
const doc = resource.getLoroDoc();
613+
// Only import into an already-initialized LoroDoc (i.e. one being actively edited).
614+
// For read-only resources, the server already materialized the properties —
615+
// no need to import the blob and re-materialize client-side.
616+
const doc = resource.hasLoroDoc() ? resource.getLoroDoc() : undefined;
615617

616618
if (doc) {
617619
try {
618620
doc.import(loroUpdate);
619621

620-
// Read all properties from the Loro map and update propvals
622+
// Read properties from the Loro map and update propvals directly
623+
// (bypass setUnsafe to avoid circular Loro writes)
621624
const propsMap = doc.getMap('properties');
622625
const json = propsMap?.toJSON();
623626

browser/lib/src/resource.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,11 @@ export class Resource<C extends OptionalClass = any> {
272272
this._store = store;
273273
}
274274

275+
/** Returns true if a LoroDoc has been initialized for this resource. */
276+
public hasLoroDoc(): boolean {
277+
return this._loroDoc !== undefined;
278+
}
279+
275280
/**
276281
* Returns the LoroDoc backing this resource, creating one if needed.
277282
* Returns undefined if Loro is not loaded.
@@ -669,6 +674,13 @@ export class Resource<C extends OptionalClass = any> {
669674
return this._dirty || this.commitBuilder.hasUnsavedChanges();
670675
}
671676

677+
/** Mark the resource as having unsaved local changes.
678+
* Use this when external code (e.g. Loro editor plugins) modifies the
679+
* resource's LoroDoc directly without going through `set()`. */
680+
public markDirty(): void {
681+
this._dirty = true;
682+
}
683+
672684
public getCommitsCollectionSubject(): string {
673685
// For DID subjects (or other non-HTTP URIs) we can't derive the server
674686
// origin from the subject itself — use the store's server URL instead.

browser/lib/src/websockets.ts

Lines changed: 32 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -226,42 +226,13 @@ export class WSClient {
226226
return;
227227
}
228228

229-
public subscribeResource(subject: string): void {
230-
if (this.readyState !== WebSocket.OPEN) {
231-
return;
232-
}
233-
234-
if (subject.startsWith('did:ad:commit:')) {
235-
return;
236-
}
237-
238-
try {
239-
const url = new URL(subject);
240-
241-
// For HTTP(S) URLs, check origin matches and it's not an immutable commit
242-
if (url.protocol === 'http:' || url.protocol === 'https:') {
243-
if (
244-
url.origin !== this.serverOrigin ||
245-
url.pathname.startsWith('/commits/')
246-
) {
247-
return;
248-
}
249-
}
250-
} catch {
251-
// DID subjects are not valid URLs but should still be subscribed to
252-
// (immutable did:ad:commit: subjects are already filtered above)
253-
if (!subject.startsWith('did:')) {
254-
return;
255-
}
256-
}
257-
258-
this.authPromise
259-
.catch(() => {
260-
// We don't want to log the error here, as it's already handled in the authenticate() method
261-
})
262-
.finally(() => {
263-
this.ws.send('SUBSCRIBE ' + subject);
264-
});
229+
/**
230+
* @deprecated Individual resource subscriptions are replaced by drive-wide SUBSCRIBE_QUERY.
231+
* Kept as a no-op for backward compatibility with callers.
232+
*/
233+
public subscribeResource(_subject: string): void {
234+
// No-op: we use drive-wide SUBSCRIBE_QUERY instead of per-resource SUBSCRIBE.
235+
// The drive subscription is set up in handleOpen().
265236
}
266237

267238
public unsubscribeResource(subject: string): void {
@@ -372,9 +343,12 @@ export class WSClient {
372343
// Make sure user is authenticated before sending any messages
373344
this.authenticate()
374345
.then(() => {
375-
// Subscribe to all existing subjects (subscribeResource filters commits and external origins)
376-
for (const subject of this.store.subscribers.keys()) {
377-
this.subscribeResource(subject);
346+
// Subscribe to all changes in the current drive
347+
const drive = this.store.getDrive();
348+
349+
if (drive) {
350+
const query = JSON.stringify({ drive });
351+
this.ws.send('SUBSCRIBE_QUERY ' + query);
378352
}
379353
})
380354
.catch(e => {
@@ -397,6 +371,25 @@ export class WSClient {
397371
} else if (ev.data.startsWith('LORO_EPHEMERAL_UPDATE ')) {
398372
const update = ev.data.slice(21);
399373
this.store.__handleLoroEphemeralMessage(update);
374+
} else if (ev.data.startsWith('QUERY_UPDATE ')) {
375+
const json = ev.data.slice(13);
376+
377+
try {
378+
const update = JSON.parse(json);
379+
const subjects: string[] = [
380+
...(update.added ?? []),
381+
...(update.removed ?? []),
382+
];
383+
384+
// Refetch affected resources so the store/UI updates
385+
for (const subject of subjects) {
386+
this.store.fetchResourceFromServer(subject).catch(() => {
387+
// Resource might have been deleted, that's fine
388+
});
389+
}
390+
} catch (e) {
391+
console.warn('Invalid QUERY_UPDATE:', e);
392+
}
400393
} else if (ev.data.startsWith('AUTHENTICATED')) {
401394
// Do nothing, handled by the authenticate() method
402395
} else {

0 commit comments

Comments
 (0)