@brass-build/client 0.1.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.
- package/AGENTS.md +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
package/docs/presence.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Presence (live cursors, who's-here)
|
|
2
|
+
|
|
3
|
+
`doc.presence` is the ephemeral "who is here right now" layer over an
|
|
4
|
+
open document: the roster of other live participants plus a
|
|
5
|
+
lightweight per-user `state` (cursor / camera / selection) and an
|
|
6
|
+
app-namespaced `ext` blob. It is non-null on any open
|
|
7
|
+
writable `Document`, and `null` otherwise. Presence is fully
|
|
8
|
+
ephemeral: nothing survives a disconnect.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import type { PresenceParticipant } from '@brass-build/client';
|
|
12
|
+
|
|
13
|
+
const doc = await Brass.openDocumentById('doc_abc');
|
|
14
|
+
|
|
15
|
+
// Publish your own cursor. `set` is a partial shallow-merge and is
|
|
16
|
+
// throttled to ~10 Hz internally, so call it as often as you like
|
|
17
|
+
// (e.g. on every pointermove).
|
|
18
|
+
doc.presence?.set({ state: { cursor: { x, y, z } } });
|
|
19
|
+
doc.presence?.set({ ext: { myApp: { tool: 'select' } } });
|
|
20
|
+
|
|
21
|
+
// React to everyone else. `roster` is the other participants; each
|
|
22
|
+
// carries a server-stamped `displayName` + `color` (CSS-ready string,
|
|
23
|
+
// e.g. `'#a4c8ff'`, assign directly to `style.background` or pass to
|
|
24
|
+
// canvas APIs without parsing) and their last `state`. Interpolate
|
|
25
|
+
// between updates in your render loop.
|
|
26
|
+
//
|
|
27
|
+
// `on('change', ...)` fires once on subscribe with the current roster
|
|
28
|
+
// when peers are already present (you opened a document others are
|
|
29
|
+
// already in), then on every join, leave, or `state` / `ext` update.
|
|
30
|
+
// It does not fire on subscribe when you are the only one here, and an
|
|
31
|
+
// empty roster is the default paint anyway, so one listener covers both
|
|
32
|
+
// the initial render and every change.
|
|
33
|
+
const renderRoster = (roster: PresenceParticipant[]) => {
|
|
34
|
+
for (const p of roster) renderAvatar(p.connectionId, p.color, p.state);
|
|
35
|
+
};
|
|
36
|
+
const stop = doc.presence?.on('change', renderRoster);
|
|
37
|
+
|
|
38
|
+
// `doc.close()` tears presence down for you; call `leave()` directly
|
|
39
|
+
// only if you want to drop presence while keeping the doc open.
|
|
40
|
+
doc.presence?.leave();
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Coordinate convention
|
|
44
|
+
|
|
45
|
+
The `state` block uses a fixed coordinate convention so participants
|
|
46
|
+
stay legible across different apps over the same document: **meters,
|
|
47
|
+
right-handed, Z-up, document world space**. Map your engine's
|
|
48
|
+
coordinates into it before `set` and back out when rendering remote
|
|
49
|
+
participants. The `roster` is keyed on `connectionId`, so one user
|
|
50
|
+
in two tabs shows as two participants.
|
|
51
|
+
|
|
52
|
+
## Reserved `state` sub-keys
|
|
53
|
+
|
|
54
|
+
App-set but reserved-by-convention so two apps subscribed to the
|
|
55
|
+
same document agree on what each key means. Pick from this set in
|
|
56
|
+
preference to inventing new ones:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
interface PresenceState {
|
|
60
|
+
// The user's primary pointer position in the document's world
|
|
61
|
+
// space. Use for "this is where my cursor is right now" in a 2D
|
|
62
|
+
// or 3D editor. Omit if your app has no cursor concept.
|
|
63
|
+
cursor?: { x: number; y: number; z: number };
|
|
64
|
+
// Where the user is looking from in 3D. Use to render a
|
|
65
|
+
// "Bob is viewing the south facade" gizmo at the camera's
|
|
66
|
+
// position. Omit in 2D apps; use `cursor` instead.
|
|
67
|
+
camera?: {
|
|
68
|
+
position: { x: number; y: number; z: number };
|
|
69
|
+
target?: { x: number; y: number; z: number };
|
|
70
|
+
};
|
|
71
|
+
// The entity ids the user has selected. Use to highlight remote
|
|
72
|
+
// selections so collaborators see what each other is working on.
|
|
73
|
+
selection?: string[];
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
App-specific state (the current tool, a chat bubble, a typing
|
|
78
|
+
indicator) goes under `ext` instead so it stays namespaced and out of
|
|
79
|
+
the cross-app conventions. The `ext` block is otherwise free-form:
|
|
80
|
+
`ext.myApp = { tool: 'select', chatBubble: 'hi' }`.
|
|
81
|
+
|
|
82
|
+
## Types
|
|
83
|
+
|
|
84
|
+
`doc.presence` is typed `PresenceClient | null`; each `roster` entry
|
|
85
|
+
is a `PresenceParticipant` with
|
|
86
|
+
`{ connectionId, sub, displayName, avatarUrl, color, joinedAt, lastSeen,
|
|
87
|
+
state, ext }`. `displayName`, `avatarUrl`, and `color` are always
|
|
88
|
+
present as strings; `avatarUrl` is `''` when the user has no picture.
|
|
89
|
+
Both types are exported from `@brass-build/client` (alongside
|
|
90
|
+
`PresenceSetInput` and `PresenceChangeListener`) so you can annotate
|
|
91
|
+
handlers and roster state without source-reading the SDK.
|
|
92
|
+
|
|
93
|
+
## Hand-rolling the header roster (custom shell)
|
|
94
|
+
|
|
95
|
+
`Brass.mountToolbar` and `Brass.mountPresenceRoster` render the
|
|
96
|
+
who's-here roster of coloured initials for you, so prefer those. When
|
|
97
|
+
you build a fully custom shell and want the roster in your own element,
|
|
98
|
+
render it from `doc.presence.roster`, a small list of coloured
|
|
99
|
+
initials that renders the same way for every app:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import type { PresenceParticipant } from '@brass-build/client';
|
|
103
|
+
|
|
104
|
+
const renderRoster = (roster: readonly PresenceParticipant[]): void => {
|
|
105
|
+
const el = document.getElementById('roster')!;
|
|
106
|
+
el.replaceChildren(...roster.map((p) => {
|
|
107
|
+
const pip = document.createElement('span');
|
|
108
|
+
pip.className = 'pip';
|
|
109
|
+
pip.style.background = p.color;
|
|
110
|
+
pip.title = p.displayName ?? p.sub ?? '';
|
|
111
|
+
if (p.avatarUrl) {
|
|
112
|
+
const img = document.createElement('img');
|
|
113
|
+
img.src = p.avatarUrl;
|
|
114
|
+
img.alt = '';
|
|
115
|
+
img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%';
|
|
116
|
+
pip.appendChild(img);
|
|
117
|
+
} else {
|
|
118
|
+
pip.textContent = (p.displayName ?? p.sub ?? '?').slice(0, 1).toUpperCase();
|
|
119
|
+
}
|
|
120
|
+
return pip;
|
|
121
|
+
}));
|
|
122
|
+
};
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Drive it from the same `change` subscription as the in-surface remote
|
|
126
|
+
cursors (see the README's "Presence" section). The listener fires on
|
|
127
|
+
subscribe whenever peers are already present, so it paints the initial
|
|
128
|
+
roster for you; render `doc.presence?.roster ?? []` yourself only if you
|
|
129
|
+
want the empty state on screen before anyone joins.
|
package/docs/schemas.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Schemas
|
|
2
|
+
|
|
3
|
+
A schema declares what your app expects a document to hold. Declaring one
|
|
4
|
+
in your `brass-app.json` routes matching documents to your app, and lets
|
|
5
|
+
any developer who can open one of those documents build against the same
|
|
6
|
+
shape.
|
|
7
|
+
|
|
8
|
+
## Declaring a schema
|
|
9
|
+
|
|
10
|
+
A schema is a `family` and a `streams` map. Each entry is a plain
|
|
11
|
+
[JSON Schema](https://json-schema.org) (draft 2020-12) describing that
|
|
12
|
+
stream's root collections and records.
|
|
13
|
+
|
|
14
|
+
- **`family`** is a short word naming what the artifact is: the domain or
|
|
15
|
+
standard it projects. Use the word the standards world already uses when
|
|
16
|
+
there is one, and the domain's own word when there is not, so a CNC
|
|
17
|
+
toolpath is `gcode` and a shift schedule is `roster`. It is the identity
|
|
18
|
+
other apps converge on, so it names the artifact rather than your app or
|
|
19
|
+
your version: `gcode`, not `acme-cam` and not `gcode-2`. Lowercase
|
|
20
|
+
`a-z 0-9 . _ -`, up to 64 characters, the same rule a stream name follows,
|
|
21
|
+
so a namespaced `acme.roster` works and a URL does not. A declaration whose family is missing or not a word is
|
|
22
|
+
not recorded, and your app's dashboard page says so.
|
|
23
|
+
- **`streams`** maps each stream name your app expects a document to hold
|
|
24
|
+
to that stream's contract. `default` is the stream a document opens
|
|
25
|
+
without asking for one, and takes an entry like any other name. Two
|
|
26
|
+
streams that carry the same shape carry the same body.
|
|
27
|
+
- **A collection of records** is an object whose `additionalProperties`
|
|
28
|
+
is the record schema, so the keys are record ids. An ordered collection
|
|
29
|
+
is an array, or an array of keys alongside the map.
|
|
30
|
+
- **Foreign keys** ride as an `x-brass-ref` extension on the referring
|
|
31
|
+
field, an object with a `to` naming the collection referred to.
|
|
32
|
+
Standard validators ignore an `x-` keyword.
|
|
33
|
+
- **The `x-brass-` prefix is reserved for Brass.** Give your own extensions
|
|
34
|
+
their own prefix (`x-acme-`), so a name you choose keeps its meaning when
|
|
35
|
+
Brass defines a keyword later. A keyword in a pulled body describes the
|
|
36
|
+
data rather than your app, so keep the body verbatim rather than dropping
|
|
37
|
+
keywords you do not recognise.
|
|
38
|
+
- **`description`** carries the conventions structure cannot: an
|
|
39
|
+
ordering, a unit, an invariant. Put it wherever the convention belongs.
|
|
40
|
+
|
|
41
|
+
Brass never keys on `$id`, and never enforces a schema against a
|
|
42
|
+
document. It validates that each body is well-formed JSON Schema when
|
|
43
|
+
your manifest is ingested, and nothing after that.
|
|
44
|
+
|
|
45
|
+
To work with documents that already exist, take their schema rather than
|
|
46
|
+
writing your own: open one in the dashboard and use **Download
|
|
47
|
+
brass-app.json**, or run `npx @brass-build/cli schema pull --doc <docId>`.
|
|
48
|
+
Keep it verbatim. Your copy is the contract your code is built against,
|
|
49
|
+
and an approximation reads as a different contract.
|
|
50
|
+
|
|
51
|
+
## Creating a stream
|
|
52
|
+
|
|
53
|
+
Streams let one document load in parts, so an app pays for what it opens
|
|
54
|
+
rather than the document's total size. `doc.getOrCreateStream(name)`
|
|
55
|
+
opens the document's stream of that name and makes it when the document
|
|
56
|
+
does not hold it yet; `doc.stream(name)` opens one the document already
|
|
57
|
+
holds and rejects any other name.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const stream = await doc.getOrCreateStream(name);
|
|
61
|
+
stream.map('records').mutate((m) => {
|
|
62
|
+
m[id] = record;
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`getOrCreateStream` is idempotent, and concurrent callers converge on one
|
|
67
|
+
stream, so an app reaches for the stream it expects on every open without
|
|
68
|
+
checking first. Only that call writes, so a read-only open reads through
|
|
69
|
+
`doc.stream` and handles the rejection when the document does not hold
|
|
70
|
+
the stream. Names follow the same rule a family does: `a-z 0-9 . _ -`, 1 to
|
|
71
|
+
64 characters, starting with a letter or digit.
|
|
72
|
+
|
|
73
|
+
A separate stream suits records only some opens need to load, or that an
|
|
74
|
+
exporter should leave out. Name it in `streams` and its shape is
|
|
75
|
+
declared, so any app that expects it reads the same contract. See
|
|
76
|
+
[Streams](./streams.md) for partitioning, undo, and reading a document
|
|
77
|
+
spread across several.
|
|
78
|
+
|
|
79
|
+
## What an app can open
|
|
80
|
+
|
|
81
|
+
An app opens a document when they share a family and **everything the app
|
|
82
|
+
requires the document already guarantees.** That is the whole test, and
|
|
83
|
+
it runs per stream, over the streams both sides name.
|
|
84
|
+
|
|
85
|
+
It is deliberately loose. Your schema does not have to match a document's
|
|
86
|
+
exactly, only ask no more of it, so an app opens documents written
|
|
87
|
+
against shapes it was never built for.
|
|
88
|
+
|
|
89
|
+
An exporter composes the streams it declares, so an app exporting a
|
|
90
|
+
document writes the parts it knows about and leaves the rest. Two apps
|
|
91
|
+
can export the same document differently, each emitting what it
|
|
92
|
+
understands.
|
|
93
|
+
|
|
94
|
+
## What breaks compatibility
|
|
95
|
+
|
|
96
|
+
Because the test is about `required`, that is the only part of a schema
|
|
97
|
+
that can break anything.
|
|
98
|
+
|
|
99
|
+
- **Removing a required property breaks other apps.** Your documents stop
|
|
100
|
+
guaranteeing that field, so apps requiring it stop opening them. Your
|
|
101
|
+
own app keeps working, so you will not see it.
|
|
102
|
+
- **Adding a required property narrows what you can open**, since older
|
|
103
|
+
documents never promised it.
|
|
104
|
+
- **Adding an optional property is safe** in both directions.
|
|
105
|
+
|
|
106
|
+
Two habits follow. Keep records open (no `additionalProperties: false`)
|
|
107
|
+
so your app preserves fields it does not recognise rather than dropping
|
|
108
|
+
them on write, and handle a missing field gracefully, since Brass
|
|
109
|
+
enforces nothing and a writer may not have written it.
|
|
110
|
+
|
|
111
|
+
## Adding a required property anyway
|
|
112
|
+
|
|
113
|
+
Declare a `writes` map beside `streams` when the two sides differ. It
|
|
114
|
+
takes the same stream names and the same bodies, and it is what your app
|
|
115
|
+
guarantees when it writes, where `streams` is what it expects to read.
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"family": "building",
|
|
120
|
+
"streams": { "default": { "type": "object", "required": ["walls"] } },
|
|
121
|
+
"writes": {
|
|
122
|
+
"default": { "type": "object", "required": ["walls", "site"] }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
That app opens every document guaranteeing `walls`, including ones
|
|
128
|
+
written before `site` existed, and every document it saves guarantees
|
|
129
|
+
`site` as well. An app still on the old shape reads the result, because
|
|
130
|
+
the document now guarantees more than that app expects. Leave `writes`
|
|
131
|
+
out and one contract covers both sides, which is what most apps want.
|
|
132
|
+
|
|
133
|
+
The two directions are the whole rule: an app opens a document that
|
|
134
|
+
guarantees at least what the app expects, and writes one whose guarantees
|
|
135
|
+
its own writes cover.
|
package/docs/streams.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Streams: partitioning a document for lazy loading
|
|
2
|
+
|
|
3
|
+
A document is a container of **streams**: named units of document
|
|
4
|
+
state that load independently. Opening a document costs only the
|
|
5
|
+
streams your app touches: snapshot download, CRDT import, and memory
|
|
6
|
+
all scale with what you use, not with the document's total size. The
|
|
7
|
+
motivating shape is a multi-discipline design: architectural,
|
|
8
|
+
plumbing, HVAC, and structural as four streams of one document, where
|
|
9
|
+
a plumbing tool opens one of them and pays a quarter of the cost.
|
|
10
|
+
Every stream is collaborative CRDT state.
|
|
11
|
+
|
|
12
|
+
Every document has a stream named **`default`**, and an open that names
|
|
13
|
+
no stream opens that one, so the doc-level handles (`doc.map(...)`,
|
|
14
|
+
`doc.orderedMap(...)`) read it. An unpartitioned document is just a
|
|
15
|
+
document whose content lives entirely there: nothing about streams needs
|
|
16
|
+
to be known or paid for until you partition. Name another stream on the
|
|
17
|
+
open (below) and the doc-level handles read that one instead.
|
|
18
|
+
|
|
19
|
+
## Opening and editing a stream
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const stream = await doc.stream(name);
|
|
23
|
+
const records = stream.orderedMap('instances', 'instance_order');
|
|
24
|
+
records.mutate((m) => {
|
|
25
|
+
m[id] = { type, params };
|
|
26
|
+
});
|
|
27
|
+
records.subscribe((change) => render(change));
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`doc.stream(name)` resolves once per name: the first call downloads the
|
|
31
|
+
stream's snapshot and subscribes it live, and repeat calls return the same
|
|
32
|
+
open. It only reads, so a name the document does not hold rejects rather
|
|
33
|
+
than being created. `doc.getOrCreateStream(name)` is the call that makes
|
|
34
|
+
one. It is idempotent and concurrent callers converge on one stream, so an
|
|
35
|
+
app reaches for the stream it expects on every open without checking
|
|
36
|
+
first. A `DocumentStream` carries the document's own reading and writing
|
|
37
|
+
surface scoped to that stream: `map` / `list` / `orderedMap` handles,
|
|
38
|
+
`mutate`, `subscribe`, and `undo` / `redo`.
|
|
39
|
+
|
|
40
|
+
## Opening at a stream
|
|
41
|
+
|
|
42
|
+
`Brass.openDocumentById(docId, { stream })` opens the document with that
|
|
43
|
+
stream as its own state, so `doc.map(...)` reads it and the rest of the
|
|
44
|
+
document is never downloaded. An app that works in one part of a large
|
|
45
|
+
document pays for that part alone:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const doc = await Brass.openDocumentById(docId, { stream: 'hvac' });
|
|
49
|
+
doc.orderedMap('instances', 'instance_order').read(); // hvac's records
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`stream` defaults to `'default'`, the stream every document has, so an
|
|
53
|
+
open always names a stream and the usual one needs no argument.
|
|
54
|
+
`doc.stream(name)` resolves the name the document was opened at back to
|
|
55
|
+
the document itself, with no second join, and any other name to its own
|
|
56
|
+
line. So the loop below reads the same whichever stream you opened at:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
for (const info of await doc.listStreams()) {
|
|
60
|
+
const stream = await doc.stream(info.name);
|
|
61
|
+
summarize(stream.map('instances').read());
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`listStreams()` includes it like any other name, so a loop over the
|
|
66
|
+
manifest needs no separate step for the stream you opened at. Names are
|
|
67
|
+
`a-z 0-9 . _ -`, 1 to 64 chars, starting with a letter or digit, the same
|
|
68
|
+
rule a schema family follows. `default` is
|
|
69
|
+
one of them: every document already holds it, so `getOrCreateStream`
|
|
70
|
+
resolves it without a request, and a loop over the names your schema
|
|
71
|
+
declares needs no case for it. `getOrCreateStream` writes, so a read-only
|
|
72
|
+
open reads with `doc.stream` instead and shows a degraded view when the
|
|
73
|
+
document does not hold the stream.
|
|
74
|
+
|
|
75
|
+
## Undo and editing model
|
|
76
|
+
|
|
77
|
+
Each stream is its own collaborative unit: concurrent edits merge per
|
|
78
|
+
stream, and each stream keeps its own local undo stack
|
|
79
|
+
(`stream.undo()` / `stream.redo()`). The doc-level `doc.undo()` covers
|
|
80
|
+
only the default stream.
|
|
81
|
+
|
|
82
|
+
A stream's local edits group into one undo step until the group is
|
|
83
|
+
**sealed** with `stream.sealUndoStep()`, the same same-stroke
|
|
84
|
+
collapsing doc-level undo gets from checkpoints, exposed directly
|
|
85
|
+
because streams carry no checkpoint machinery. Seal when a user
|
|
86
|
+
operation completes; an app that never seals gets one giant step per
|
|
87
|
+
session.
|
|
88
|
+
|
|
89
|
+
### Undoing an operation that spans streams
|
|
90
|
+
|
|
91
|
+
When one user action writes to several streams (move a wall in
|
|
92
|
+
`architectural`, reroute its duct in `hvac`), keep an app-level
|
|
93
|
+
operation journal and undo each touched stream once per journal entry.
|
|
94
|
+
Two rules make the stacks line up:
|
|
95
|
+
|
|
96
|
+
1. **Seal every touched stream when the operation completes.** The
|
|
97
|
+
seal is what makes the operation exactly one undo step in each
|
|
98
|
+
stream, however many mutates it took.
|
|
99
|
+
2. **Route every local edit through the journal.** The alignment
|
|
100
|
+
argument is LIFO: if every operation is journaled and sealed, each
|
|
101
|
+
stream's stack top always belongs to the most recent journaled
|
|
102
|
+
operation that touched it. An edit that bypasses the journal
|
|
103
|
+
breaks that.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
type Op = { label: string; streams: string[] };
|
|
107
|
+
const undoJournal: Op[] = [];
|
|
108
|
+
const redoJournal: Op[] = [];
|
|
109
|
+
|
|
110
|
+
// Wrap every user operation: run the writes, then seal + journal.
|
|
111
|
+
async function operation(
|
|
112
|
+
label: string,
|
|
113
|
+
streams: string[],
|
|
114
|
+
run: (open: Map<string, DocumentStream>) => void,
|
|
115
|
+
) {
|
|
116
|
+
const open = new Map(
|
|
117
|
+
await Promise.all(
|
|
118
|
+
streams.map(async (n) => [n, await doc.stream(n)] as const),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
run(open);
|
|
122
|
+
for (const s of open.values()) s.sealUndoStep();
|
|
123
|
+
undoJournal.push({ label, streams });
|
|
124
|
+
redoJournal.length = 0; // a fresh edit clears redo, as usual
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await operation('Move wall', ['architectural', 'hvac'], (open) => {
|
|
128
|
+
open.get('architectural')!.map('instances').mutate((m) => {
|
|
129
|
+
/* move the wall */
|
|
130
|
+
});
|
|
131
|
+
open.get('hvac')!.map('instances').mutate((m) => {
|
|
132
|
+
/* reroute the attached duct */
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
async function undoOperation() {
|
|
137
|
+
const op = undoJournal.pop();
|
|
138
|
+
if (!op) return;
|
|
139
|
+
// Reverse application order, so dependent changes unwind last-first.
|
|
140
|
+
for (const name of [...op.streams].reverse()) {
|
|
141
|
+
(await doc.stream(name)).undo();
|
|
142
|
+
}
|
|
143
|
+
redoJournal.push(op);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function redoOperation() {
|
|
147
|
+
const op = redoJournal.pop();
|
|
148
|
+
if (!op) return;
|
|
149
|
+
for (const name of op.streams) {
|
|
150
|
+
(await doc.stream(name)).redo();
|
|
151
|
+
}
|
|
152
|
+
undoJournal.push(op);
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Bind your keyboard shortcut and undo button to `undoOperation()`
|
|
157
|
+
rather than `doc.undo()` (which covers only the default stream).
|
|
158
|
+
Collaborators' edits do not disturb the alignment: undo stacks are
|
|
159
|
+
local-peer-scoped, and each stream's undo rebases your reverted step
|
|
160
|
+
over remote changes the same way single-document undo does.
|
|
161
|
+
|
|
162
|
+
Two properties to design around rather than against. The cross-stream
|
|
163
|
+
undo is **not atomic**: each stream's revert is its own commit, so a
|
|
164
|
+
collaborator can briefly observe one stream reverted before the other
|
|
165
|
+
(the same window any multi-stream operation has). And the journal is
|
|
166
|
+
**in-memory**: a reload starts it empty, exactly like single-document
|
|
167
|
+
undo. If an operation genuinely cannot tolerate the window, that is
|
|
168
|
+
the signal its data belongs in one stream: partition by load unit, and
|
|
169
|
+
keep records that must change together in the same stream.
|
|
170
|
+
|
|
171
|
+
## When to partition
|
|
172
|
+
|
|
173
|
+
Opening a stream costs a request for its snapshot, and the first one on a
|
|
174
|
+
document also reads the stream manifest, so add a stream when it carries
|
|
175
|
+
real content that some opens can skip. Partition by **load unit** (a
|
|
176
|
+
discipline, a heavy layer, a dataset a specific tool reads), not by
|
|
177
|
+
concept. A handful of streams with megabytes each works best; dozens of
|
|
178
|
+
tiny streams make opening everything slower
|
|
179
|
+
than a monolith, and content every open needs belongs in the default
|
|
180
|
+
stream.
|
|
181
|
+
|
|
182
|
+
Branches and streams compose: creating a branch forks every stream, so a
|
|
183
|
+
branch is fully isolated from its source across all of them, exactly like an
|
|
184
|
+
unpartitioned document, and each forked stream keeps the shape its records
|
|
185
|
+
conform to. Branching from a historical version addresses the default
|
|
186
|
+
stream's timeline; the other streams fork at their current state. Deleting a
|
|
187
|
+
branch deletes its streams with it. Two branches that then diverge in shape
|
|
188
|
+
report different contracts, because a contract belongs to a stream on a
|
|
189
|
+
branch rather than to the document.
|
|
190
|
+
|
|
191
|
+
## Reading a document spread across streams
|
|
192
|
+
|
|
193
|
+
Your schema names the streams it expects a document to be spread over,
|
|
194
|
+
under `streams`. A small document keeps every record in its default
|
|
195
|
+
stream; a large one moves the bulk (an IFC's geometry, an index most opens
|
|
196
|
+
skip) onto the streams the schema names, so an app that reads only part of
|
|
197
|
+
the content loads only that part. The declaration is the same either way,
|
|
198
|
+
because it says which streams your app expects, not what any one document
|
|
199
|
+
turned out to hold.
|
|
200
|
+
|
|
201
|
+
If your app follows references between records, read every stream the
|
|
202
|
+
DOCUMENT holds, because a referenced record may live in one the default
|
|
203
|
+
stream does not. `doc.listStreams()` is that list, so merge by id:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const store: Record<string, { type: string; params: unknown }> = {
|
|
207
|
+
...doc.orderedMap('instances', 'instance_order').read(),
|
|
208
|
+
};
|
|
209
|
+
for (const info of await doc.listStreams()) {
|
|
210
|
+
const stream = await doc.stream(info.name);
|
|
211
|
+
Object.assign(store, stream.orderedMap('instances', 'instance_order').read());
|
|
212
|
+
}
|
|
213
|
+
// store['#137'] resolves wherever #137 lives.
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The loop opens the stream the document was opened at without a second
|
|
217
|
+
join, so it reads the same for an unpartitioned document as for a split
|
|
218
|
+
one. Open only what you read: an app that needs just the default view's
|
|
219
|
+
records reads the default stream alone; one that resolves references
|
|
220
|
+
reads them all and pays for the streams it opens.
|
|
221
|
+
|
|
222
|
+
A document can hold streams your schema does not name (computed results,
|
|
223
|
+
review notes, a cache), so the merge above skips them and an exporter that
|
|
224
|
+
does not declare them writes nothing from them. Name a stream in your
|
|
225
|
+
schema to read it, and `doc.getOrCreateStream` makes it when the document
|
|
226
|
+
does not hold it yet.
|
|
227
|
+
|
|
228
|
+
## Exporting across streams
|
|
229
|
+
|
|
230
|
+
A registered serializer receives the live document and opens streams
|
|
231
|
+
like any other code, so an exporter collects from wherever the content
|
|
232
|
+
lives:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
Brass.registerSerializer(async (doc) => {
|
|
236
|
+
const arch = await doc.stream('architectural');
|
|
237
|
+
const hvac = await doc.stream('hvac');
|
|
238
|
+
return encodeMyFormat(
|
|
239
|
+
arch.orderedMap('instances', 'instance_order').ordered(),
|
|
240
|
+
hvac.orderedMap('instances', 'instance_order').ordered(),
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Collect everything from the document itself. Brass may run your
|
|
246
|
+
serializer detached from your app (a dashboard download, or a drive
|
|
247
|
+
publish with your app not open), so open the streams you need rather
|
|
248
|
+
than reach for your app's UI state.
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@brass-build/client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Client SDK for Brass",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./vite": {
|
|
15
|
+
"types": "./dist/vite.d.ts",
|
|
16
|
+
"import": "./dist/vite.js"
|
|
17
|
+
},
|
|
18
|
+
"./internal": {
|
|
19
|
+
"types": "./dist/internal.d.ts",
|
|
20
|
+
"import": "./dist/internal.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src",
|
|
26
|
+
"!src/**/__tests__/**",
|
|
27
|
+
"scripts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"AGENTS.md",
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"docs",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -b",
|
|
37
|
+
"type-check": "tsc -b",
|
|
38
|
+
"prepare": "node ./scripts/prepare.mjs",
|
|
39
|
+
"prepublishOnly": "tsc -b --force"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"fflate": "^0.8.3",
|
|
43
|
+
"loro-crdt": "^1.12.1"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"typescript": "~6.0.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/brass-build/client.git"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://brass.build",
|
|
59
|
+
"license": "MIT"
|
|
60
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// `prepare` runs on `npm install`, both for direct installs (`npm i`
|
|
2
|
+
// in `sdk/` itself) and for file-dep consumers (`"@brass-build/client":
|
|
3
|
+
// "file:../sdk"`). We want one script that:
|
|
4
|
+
//
|
|
5
|
+
// 1. Skips the build when `dist/index.js` already exists. A consumer
|
|
6
|
+
// that installs a prebuilt SDK should not have to re-run `tsc` on
|
|
7
|
+
// every `npm install`, and an environment without a resolvable
|
|
8
|
+
// `tsc` (a fresh sandbox, a CI runner that doesn't install the
|
|
9
|
+
// SDK's own devDeps when pulling it as a file dep) would otherwise
|
|
10
|
+
// fail the consumer's `npm install` outright.
|
|
11
|
+
// 2. Runs `tsc -b` from this directory when `dist/` is missing, so a
|
|
12
|
+
// direct `npm install` in `sdk/` still emits the bundle.
|
|
13
|
+
//
|
|
14
|
+
// `tsc` is resolved through `node_modules/.bin/` of this package, so
|
|
15
|
+
// it works whether the consumer has a global `tsc` or not, as long as
|
|
16
|
+
// the SDK's own devDeps installed (which they will under a plain
|
|
17
|
+
// `npm i` in `sdk/`). When this script runs from a file-dep consumer
|
|
18
|
+
// install, dist/ is already present in the file: target and we exit
|
|
19
|
+
// before invoking tsc.
|
|
20
|
+
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const sdkRoot = dirname(here);
|
|
28
|
+
const builtEntry = join(sdkRoot, 'dist', 'index.js');
|
|
29
|
+
|
|
30
|
+
if (existsSync(builtEntry)) {
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tscBin = process.platform === 'win32' ? 'tsc.cmd' : 'tsc';
|
|
35
|
+
const localTsc = join(sdkRoot, 'node_modules', '.bin', tscBin);
|
|
36
|
+
const command = existsSync(localTsc) ? localTsc : tscBin;
|
|
37
|
+
|
|
38
|
+
const result = spawnSync(command, ['-b'], { stdio: 'inherit', cwd: sdkRoot });
|
|
39
|
+
if (result.error || (result.status ?? 0) !== 0) {
|
|
40
|
+
// Don't fail the install: a consumer who pulled the SDK as a file
|
|
41
|
+
// dep without dist/ and without typescript available can still
|
|
42
|
+
// import `src/` via the `files` whitelist, and the SDK's primary
|
|
43
|
+
// consumption path is the prebuilt `dist/`. A loud warning here
|
|
44
|
+
// lets the operator notice; a hard exit would block the whole
|
|
45
|
+
// dependency install for a working setup.
|
|
46
|
+
console.warn(
|
|
47
|
+
'[@brass-build/client] prepare: tsc -b did not run cleanly; ' +
|
|
48
|
+
'install is continuing without rebuilding dist/.',
|
|
49
|
+
);
|
|
50
|
+
}
|