@archastro/sdk 0.8.0 → 0.9.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 (38) hide show
  1. package/README.md +3 -0
  2. package/dist/app-session.d.ts +3 -3
  3. package/dist/app-session.d.ts.map +1 -1
  4. package/dist/app-session.js +2 -3
  5. package/dist/app-session.js.map +1 -1
  6. package/dist/client.d.ts +18 -5
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +13 -8
  9. package/dist/client.js.map +1 -1
  10. package/dist/custom-object-subscriptions.d.ts +94 -0
  11. package/dist/custom-object-subscriptions.d.ts.map +1 -0
  12. package/dist/custom-object-subscriptions.js +549 -0
  13. package/dist/custom-object-subscriptions.js.map +1 -0
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/phx_channel/channel.d.ts +8 -0
  19. package/dist/phx_channel/channel.d.ts.map +1 -1
  20. package/dist/phx_channel/channel.js +36 -1
  21. package/dist/phx_channel/channel.js.map +1 -1
  22. package/dist/phx_channel/socket.d.ts.map +1 -1
  23. package/dist/phx_channel/socket.js +4 -0
  24. package/dist/phx_channel/socket.js.map +1 -1
  25. package/dist/platform-socket.d.ts +5 -2
  26. package/dist/platform-socket.d.ts.map +1 -1
  27. package/dist/platform-socket.js +6 -5
  28. package/dist/platform-socket.js.map +1 -1
  29. package/dist/runtime/http-client.d.ts +21 -0
  30. package/dist/runtime/http-client.d.ts.map +1 -1
  31. package/dist/runtime/http-client.js +28 -0
  32. package/dist/runtime/http-client.js.map +1 -1
  33. package/dist/runtime/url.d.ts +1 -1
  34. package/dist/runtime/url.d.ts.map +1 -1
  35. package/dist/runtime/url.js +3 -2
  36. package/dist/runtime/url.js.map +1 -1
  37. package/docs/custom-object-collaboration.md +248 -0
  38. package/package.json +3 -2
@@ -0,0 +1,248 @@
1
+ ---
2
+ title: Custom-object collaboration
3
+ group: Guides
4
+ ---
5
+
6
+ # Custom-object collaboration
7
+
8
+ Custom objects combine persisted JSON fields with a realtime channel. Use nested
9
+ maps for independently edited records so one user's change does not replace
10
+ another user's unrelated work.
11
+
12
+ ## Same-origin browser client
13
+
14
+ When a backend owns the platform credential in an HttpOnly session cookie,
15
+ construct one client for the authenticated browser session and point both HTTP
16
+ and WebSocket traffic at that backend:
17
+
18
+ ```ts
19
+ import {
20
+ PlatformClient,
21
+ withCustomObjectSubscriptions,
22
+ } from "@archastro/sdk";
23
+
24
+ const RealtimePlatformClient = PlatformClient.extend(
25
+ withCustomObjectSubscriptions,
26
+ );
27
+
28
+ export const archAstro = new RealtimePlatformClient({
29
+ baseUrl: window.location.origin,
30
+ pathPrefix: "/api/archastro/platform",
31
+ credentials: "include",
32
+ });
33
+ ```
34
+
35
+ `extend` uses the TypeScript class-expression mixin pattern. It returns a new
36
+ client class, so generated resources and static factories remain available and
37
+ the compiler infers `customObjectSubscriptions` on its instances. Extensions
38
+ can be chained by calling `extend` again on the returned class.
39
+
40
+ The SDK sends HTTP requests to the configured `pathPrefix`. WebSocket
41
+ subscriptions connect to `socketPath`; the browser includes same-origin cookies
42
+ during the upgrade. Access and refresh tokens therefore remain unavailable to
43
+ browser JavaScript. The backend must authenticate the cookie before proxying
44
+ HTTP requests or accepting the WebSocket upgrade.
45
+
46
+ The extension derives `/api/archastro/platform/socket` from the configured
47
+ `pathPrefix`. For a nonstandard route, pass
48
+ `customObjectSubscriptionsExtension({ socketPath })` to `extend` instead.
49
+
50
+ ## List and create team-owned objects
51
+
52
+ App scope comes from the authenticated session, so these calls do not take an
53
+ app ID. Team ownership is explicit:
54
+
55
+ ```ts
56
+ const page = await archAstro.custom_objects.list({
57
+ type: "archcode-diagram",
58
+ team: [teamId],
59
+ pageSize: 100,
60
+ });
61
+
62
+ const created = await archAstro.custom_objects.create({
63
+ type: "archcode-diagram",
64
+ team: teamId,
65
+ fields: {
66
+ diagram_key: crypto.randomUUID(),
67
+ repository: "firstlanding",
68
+ title: "Request path",
69
+ elements_by_id: {},
70
+ files_by_id: {},
71
+ comments_by_id: {},
72
+ metadata: {},
73
+ },
74
+ });
75
+ ```
76
+
77
+ ## Subscribe, update, save, and close
78
+
79
+ ```ts
80
+ import { z } from "zod";
81
+ import type {
82
+ CustomObjectConnectionState,
83
+ CustomObjectSubscription,
84
+ } from "@archastro/sdk";
85
+
86
+ const DiagramFields = z.object({
87
+ diagram_key: z.string(),
88
+ repository: z.string(),
89
+ title: z.string(),
90
+ elements_by_id: z.record(z.record(z.unknown())),
91
+ files_by_id: z.record(z.record(z.unknown())),
92
+ comments_by_id: z.record(z.record(z.unknown())),
93
+ metadata: z.record(z.unknown()),
94
+ });
95
+
96
+ type DiagramFields = z.infer<typeof DiagramFields>;
97
+
98
+ let applyingRemote = false;
99
+ let state: CustomObjectConnectionState = "connecting";
100
+
101
+ const subscription: CustomObjectSubscription<DiagramFields> =
102
+ archAstro.customObjectSubscriptions.subscribe({
103
+ objectId: created.id,
104
+ connectionId: perTabConnectionId,
105
+ fieldsSchema: DiagramFields,
106
+
107
+ // Called for the initial join and again after every reconnect. A reconnect
108
+ // snapshot is authoritative and arrives before queued idempotent patches
109
+ // are replayed.
110
+ onSnapshot(snapshot) {
111
+ setReadonly(snapshot.readonly === true);
112
+ applyingRemote = true;
113
+ try {
114
+ replaceMaterializedDocument(snapshot.fields);
115
+ } finally {
116
+ applyingRemote = false;
117
+ }
118
+ },
119
+
120
+ // object_updated can contain a partial update or a complete fields map.
121
+ // Compare canonical content before applying it.
122
+ onUpdate(update) {
123
+ if (alreadyMaterialized(update.fields)) return;
124
+ applyingRemote = true;
125
+ try {
126
+ mergeMaterializedDocument(update.fields);
127
+ } finally {
128
+ applyingRemote = false;
129
+ }
130
+ },
131
+
132
+ onPresence(collaborator) {
133
+ renderCollaborator(collaborator);
134
+ },
135
+
136
+ onPresenceLeave(collaborator) {
137
+ removeCollaborator(collaborator.connectionId);
138
+ },
139
+
140
+ onStateChange(nextState) {
141
+ state = nextState;
142
+ renderConnectionState(nextState);
143
+ },
144
+
145
+ onError(error) {
146
+ reportCollaborationError(error);
147
+ },
148
+ });
149
+
150
+ // Nested map assignment: safe to replay because it is idempotent.
151
+ await subscription.update({
152
+ elements_by_id: {
153
+ "element-42": {
154
+ x: 420,
155
+ y: 180,
156
+ updated_by: currentUser.id,
157
+ },
158
+ },
159
+ });
160
+
161
+ // Presence is ephemeral and is never written into custom-object fields.
162
+ await subscription.updatePresence({
163
+ cursor: { x: 420, y: 180 },
164
+ selectedElementIds: ["element-42"],
165
+ activity: "active",
166
+ });
167
+
168
+ await subscription.save();
169
+ subscription.close();
170
+ ```
171
+
172
+ Connection states have these meanings:
173
+
174
+ - `connecting`: the initial transport and channel join are in progress.
175
+ - `live`: the current snapshot is materialized and queued patches are acknowledged.
176
+ - `reconnecting`: transport recovery is in progress.
177
+ - `offline`: the browser reports that network connectivity is unavailable.
178
+ - `unauthorized`: authentication or authorization failed. This state is terminal.
179
+ - `closed`: the caller closed the subscription or the object no longer exists.
180
+
181
+ Each snapshot also preserves the server's `readonly` flag and resolved
182
+ `connectionId`. Initial collaborators from the join response are delivered
183
+ through `onPresence` before the subscription becomes `live`.
184
+
185
+ `close()` removes channel listeners, stops reconnect attempts, rejects pending
186
+ queued updates, leaves the channel when possible, and closes the socket.
187
+
188
+ ## React effect cleanup
189
+
190
+ ```tsx
191
+ useEffect(() => {
192
+ const subscription =
193
+ archAstro.customObjectSubscriptions.subscribe<DiagramFields>({
194
+ objectId: diagramId,
195
+ fieldsSchema: DiagramFields,
196
+ onSnapshot: setDocumentFromSnapshot,
197
+ onUpdate: mergeRemoteUpdate,
198
+ onPresence: upsertCollaborator,
199
+ onPresenceLeave: removeCollaborator,
200
+ onStateChange: setConnectionState,
201
+ onError: setCollaborationError,
202
+ });
203
+
204
+ return () => {
205
+ subscription.close();
206
+ };
207
+ }, [diagramId]);
208
+ ```
209
+
210
+ Create one subscription per mounted document. Always close the old subscription
211
+ before subscribing to another object; this prevents duplicate listeners after
212
+ navigation or React effect re-runs.
213
+
214
+ ## Retry and convergence rules
215
+
216
+ Plain nested-map assignments sent through `subscription.update()` are
217
+ idempotent. While disconnected, the SDK compacts superseded nested assignments.
218
+ After reconnect it:
219
+
220
+ 1. authenticates and joins again;
221
+ 2. delivers the authoritative current snapshot;
222
+ 3. replays compacted idempotent assignments;
223
+ 4. waits for acknowledgements;
224
+ 5. transitions to `live`.
225
+
226
+ Do not blindly retry array append/prepend/remove operations, creates without a
227
+ stable upsert key, deletes, or another mutation whose first acknowledgement may
228
+ have been lost. HTTP reads are safe for the application to retry with bounded
229
+ backoff. Do not retry authentication, authorization, or validation errors.
230
+
231
+ Nested maps are preferable to one shared array for collaborative documents.
232
+ Updating `elements_by_id.element-42.x` leaves other elements and other properties
233
+ untouched, while replacing an array makes the entire collection one conflict
234
+ unit.
235
+
236
+ Presence remains ephemeral because cursors, selections, idle state, and browser
237
+ connection IDs have no durable document meaning. Persisting them would create
238
+ stale collaborators and unnecessary custom-object writes.
239
+
240
+ Snapshot and remote-update handlers must use an internal guard, as shown above,
241
+ so applying remote state does not produce a local outgoing mutation. Also compare
242
+ canonical values rather than timestamps alone.
243
+
244
+ If access is revoked, the subscription reports an authentication or
245
+ authorization error, transitions to `unauthorized`, stops reconnecting, and
246
+ rejects further durable updates. The application should disable editing while
247
+ leaving navigation and export of already authorized local data accessible as
248
+ appropriate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archastro/sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "TypeScript SDK for the ArchAstro Platform API (Node, browser, React Native)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,9 +15,10 @@
15
15
  "build": "tsc",
16
16
  "clean": "rm -rf dist",
17
17
  "docs": "typedoc --options typedoc.json",
18
- "lint": "tsc --noEmit",
18
+ "lint": "tsc --noEmit && npm run test:types",
19
19
  "prepublishOnly": "npm run build",
20
20
  "test": "vitest run",
21
+ "test:types": "npm run build && tsc -p tsconfig.type-tests.json",
21
22
  "test:contract": "vitest run --config __tests__/contract/vitest.contract.config.ts"
22
23
  },
23
24
  "license": "MIT",