k0-board 0.5.0 → 0.6.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/CHANGELOG.md +66 -1
- package/LICENSE +83 -18
- package/README.md +238 -36
- package/bin/k0-board.js +48 -1
- package/package.json +2 -2
- package/server/cloud.js +355 -0
- package/server/db.js +375 -58
- package/server/index.js +92 -2
- package/server/mirror.js +21 -0
- package/server/sync.js +383 -0
- package/server/update.js +3 -2
- package/web/account.js +265 -0
- package/web/base.css +93 -0
- package/web/index.html +1 -0
package/bin/k0-board.js
CHANGED
|
@@ -471,6 +471,8 @@ async function doctor() {
|
|
|
471
471
|
else if (state.running) warn('the service is running but the server is not answering')
|
|
472
472
|
else warn('not running — `k0-board install` or `k0-board start`')
|
|
473
473
|
|
|
474
|
+
await cloudReport()
|
|
475
|
+
|
|
474
476
|
step('What this machine can do')
|
|
475
477
|
const rows = [
|
|
476
478
|
['open terminals', capabilities.terminal.pasteWithoutSending || capabilities.terminal.readScreen],
|
|
@@ -514,6 +516,49 @@ async function doctor() {
|
|
|
514
516
|
}
|
|
515
517
|
}
|
|
516
518
|
|
|
519
|
+
// ── k0 Cloud ─────────────────────────────────────────────────────────────────
|
|
520
|
+
/**
|
|
521
|
+
* Signing in happens in the browser, on the board: that is where GitHub or Google send you back
|
|
522
|
+
* to. So this opens the board, which shows the way in when nobody is signed in yet, and says
|
|
523
|
+
* where things stand when somebody is.
|
|
524
|
+
*/
|
|
525
|
+
async function login() {
|
|
526
|
+
if (!(await alive())) {
|
|
527
|
+
fail('k0 is not running — `k0-board install` or `k0-board start` first')
|
|
528
|
+
process.exit(1)
|
|
529
|
+
}
|
|
530
|
+
const s = await api('/api/cloud')
|
|
531
|
+
if (!s.enabled) return warn('k0 Cloud is switched off on this machine (K0_CLOUD=off)')
|
|
532
|
+
if (s.linked && s.approved) return ok(`signed in as ${s.email || s.name}`)
|
|
533
|
+
if (s.linked) return note(`signed in as ${s.email || s.name}, waiting to be let in — the board notices by itself`)
|
|
534
|
+
const url = await openBoard()
|
|
535
|
+
say(`Sign in on the board: ${url}`)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function logout() {
|
|
539
|
+
if (!(await alive())) {
|
|
540
|
+
fail('k0 is not running')
|
|
541
|
+
process.exit(1)
|
|
542
|
+
}
|
|
543
|
+
await api('/api/cloud/logout', {})
|
|
544
|
+
ok('signed out — the board stays on this machine, and stops syncing')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function cloudReport() {
|
|
548
|
+
step('k0 Cloud')
|
|
549
|
+
if (!(await alive())) return warn('the server is not running, so there is nothing to ask')
|
|
550
|
+
const s = await api('/api/cloud').catch(() => null)
|
|
551
|
+
if (!s) return warn('the server did not say')
|
|
552
|
+
if (!s.enabled) return warn('switched off on this machine (K0_CLOUD=off)')
|
|
553
|
+
if (!s.linked) return warn('nobody is signed in — `k0-board login`')
|
|
554
|
+
ok(`signed in as ${s.email || s.name}`)
|
|
555
|
+
;(s.approved ? ok : warn)(s.approved ? 'let in' : 'waiting to be let in')
|
|
556
|
+
if (s.needs_move) warn('the board on this machine has not been moved to the account yet — the board offers it')
|
|
557
|
+
;(s.online === false ? warn : ok)(s.online === false ? 'k0 Cloud cannot be reached right now' : 'k0 Cloud answers')
|
|
558
|
+
if (s.pending) note(`${s.pending} change${s.pending === 1 ? '' : 's'} waiting to go up`)
|
|
559
|
+
if (s.sync_error) warn(s.sync_error)
|
|
560
|
+
}
|
|
561
|
+
|
|
517
562
|
// ── the rest ─────────────────────────────────────────────────────────────────
|
|
518
563
|
async function restart() {
|
|
519
564
|
await service.restart()
|
|
@@ -547,6 +592,8 @@ ${bold('k0-board')} — a board of sticky notes that drives your Claude Code ses
|
|
|
547
592
|
${bold('k0-board restart')} restart the service
|
|
548
593
|
${bold('k0-board status')} is it running?
|
|
549
594
|
${bold('k0-board doctor')} what this machine can and cannot do, and why
|
|
595
|
+
${bold('k0-board login')} sign in to k0 Cloud with GitHub or Google (on the board)
|
|
596
|
+
${bold('k0-board logout')} sign out; the board stays on this machine
|
|
550
597
|
|
|
551
598
|
Options
|
|
552
599
|
${bold('--yes')} do not ask, assume yes
|
|
@@ -556,7 +603,7 @@ Options
|
|
|
556
603
|
`)
|
|
557
604
|
}
|
|
558
605
|
|
|
559
|
-
const commands = { install, uninstall, start, restart, status, doctor, 'shift-enter': shiftEnter }
|
|
606
|
+
const commands = { install, uninstall, start, restart, status, doctor, login, logout, 'shift-enter': shiftEnter }
|
|
560
607
|
|
|
561
608
|
if (flag('help') || flag('h') || command === 'help') {
|
|
562
609
|
usage()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "k0-board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A board of sticky notes that drives your Claude Code sessions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"type": "git",
|
|
17
17
|
"url": "git+https://github.com/alessio-ragni/k0-board.git"
|
|
18
18
|
},
|
|
19
|
-
"license": "
|
|
19
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
20
20
|
"author": "Alessio Ragni (https://alessioragni.com)",
|
|
21
21
|
"type": "module",
|
|
22
22
|
"bin": {
|
package/server/cloud.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { HOME } from './paths.js'
|
|
5
|
+
|
|
6
|
+
// ── k0 Cloud ─────────────────────────────────────────────────────────────────
|
|
7
|
+
// The only file in k0 that talks to the cloud. Everything that leaves this machine for it —
|
|
8
|
+
// signing in, the next K number, the rows of the board — goes through the few functions below,
|
|
9
|
+
// so what k0 sends and where can be read in one place. The other door out is `update.js`, which
|
|
10
|
+
// asks npm once a day what the newest version is. There is no third.
|
|
11
|
+
//
|
|
12
|
+
// The cloud is Supabase: its own sign-in (GitHub or Google, nothing else) and its own database,
|
|
13
|
+
// reached over plain HTTPS with Node's `fetch`. No client library: k0 has no dependencies, and
|
|
14
|
+
// the handful of calls it needs are each a few lines.
|
|
15
|
+
//
|
|
16
|
+
// The address and the key below are public on purpose. The key is Supabase's "publishable" one:
|
|
17
|
+
// it identifies the project and grants nothing by itself; what a signed-in person may read or
|
|
18
|
+
// write is decided by the database's own row-level rules, and an account that has not been let in
|
|
19
|
+
// may read and write nothing at all. The secret keys never appear in this repository.
|
|
20
|
+
|
|
21
|
+
const DEFAULT_URL = 'https://pegjzrbsogextluajmjm.supabase.co'
|
|
22
|
+
const DEFAULT_KEY = 'sb_publishable_Q-kGvSLvbsxiFikQxO1UEA_QiDsCamH'
|
|
23
|
+
|
|
24
|
+
/** `K0_CLOUD=off` is for working on k0 itself, and for the tests: no cloud, no sign-in, no socket. */
|
|
25
|
+
export const enabled = () => process.env.K0_CLOUD !== 'off'
|
|
26
|
+
const base = () => (process.env.K0_CLOUD_URL || DEFAULT_URL).replace(/\/+$/, '')
|
|
27
|
+
const apiKey = () => process.env.K0_CLOUD_KEY || DEFAULT_KEY
|
|
28
|
+
|
|
29
|
+
/** Where the sign-in is kept: beside the database, readable by you and nobody else. */
|
|
30
|
+
export const FILE = process.env.K0_CLOUD_FILE || path.join(HOME, 'cloud.json')
|
|
31
|
+
|
|
32
|
+
/** The two ways in. A third is a decision, not a line of code. */
|
|
33
|
+
export const PROVIDERS = ['github', 'google']
|
|
34
|
+
|
|
35
|
+
const TIMEOUT = 8000
|
|
36
|
+
const KEY_TIMEOUT = 2500
|
|
37
|
+
|
|
38
|
+
// `fetch` is replaceable so a test can play the cloud without a socket — the same seam `update.js`
|
|
39
|
+
// has, for the same reason.
|
|
40
|
+
let fetcher = (...args) => globalThis.fetch(...args)
|
|
41
|
+
export function useFetch(f) {
|
|
42
|
+
fetcher = f || ((...args) => globalThis.fetch(...args))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── What is kept on disk ─────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
let saved = null
|
|
48
|
+
let savedAt = 0
|
|
49
|
+
|
|
50
|
+
const mtime = () => {
|
|
51
|
+
try {
|
|
52
|
+
return fs.statSync(FILE).mtimeMs
|
|
53
|
+
} catch {
|
|
54
|
+
return 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Read again whenever the file changes, so a sign-in finished by another k0 process — the command
|
|
59
|
+
// line, a second server started by hand — is seen here without a restart.
|
|
60
|
+
function load() {
|
|
61
|
+
if (saved && mtime() === savedAt) return saved
|
|
62
|
+
try {
|
|
63
|
+
saved = JSON.parse(fs.readFileSync(FILE, 'utf8'))
|
|
64
|
+
} catch {
|
|
65
|
+
saved = {}
|
|
66
|
+
}
|
|
67
|
+
savedAt = mtime()
|
|
68
|
+
if (!saved.machine) {
|
|
69
|
+
saved.machine = crypto.randomUUID()
|
|
70
|
+
store()
|
|
71
|
+
}
|
|
72
|
+
return saved
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function store() {
|
|
76
|
+
try {
|
|
77
|
+
fs.mkdirSync(path.dirname(FILE), { recursive: true })
|
|
78
|
+
fs.writeFileSync(FILE, JSON.stringify(saved, null, 2), { mode: 0o600 })
|
|
79
|
+
// `mode` only applies when the file is created; one written before this line existed keeps
|
|
80
|
+
// whatever it had, so it is said again.
|
|
81
|
+
fs.chmodSync(FILE, 0o600)
|
|
82
|
+
savedAt = mtime()
|
|
83
|
+
} catch {
|
|
84
|
+
// A sign-in that cannot be written down is one that has to be done again next start. That is
|
|
85
|
+
// an annoyance, not a reason for the board to stop.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** For the tests: forget what was read, so the next call reads the file again. */
|
|
90
|
+
export function reset() {
|
|
91
|
+
saved = null
|
|
92
|
+
online = null
|
|
93
|
+
lastError = null
|
|
94
|
+
pending = null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const machine = () => load().machine
|
|
98
|
+
|
|
99
|
+
// ── Being online ─────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
let online = null
|
|
102
|
+
let lastError = null
|
|
103
|
+
|
|
104
|
+
function heard(ok, error = null) {
|
|
105
|
+
online = ok
|
|
106
|
+
lastError = ok ? null : error
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function request(method, url, { body = null, token = null, timeout = TIMEOUT, prefer = null } = {}) {
|
|
110
|
+
const headers = { apikey: apiKey(), accept: 'application/json' }
|
|
111
|
+
if (body != null) headers['content-type'] = 'application/json'
|
|
112
|
+
if (token) headers.authorization = `Bearer ${token}`
|
|
113
|
+
if (prefer) headers.prefer = prefer
|
|
114
|
+
let res
|
|
115
|
+
try {
|
|
116
|
+
res = await fetcher(url, {
|
|
117
|
+
method,
|
|
118
|
+
headers,
|
|
119
|
+
body: body == null ? undefined : JSON.stringify(body),
|
|
120
|
+
redirect: 'error',
|
|
121
|
+
signal: AbortSignal.timeout(timeout),
|
|
122
|
+
})
|
|
123
|
+
} catch (err) {
|
|
124
|
+
heard(false, 'k0 Cloud cannot be reached.')
|
|
125
|
+
const e = new Error('offline')
|
|
126
|
+
e.offline = true
|
|
127
|
+
e.cause = err
|
|
128
|
+
throw e
|
|
129
|
+
}
|
|
130
|
+
heard(true)
|
|
131
|
+
const text = await res.text()
|
|
132
|
+
let data = null
|
|
133
|
+
try {
|
|
134
|
+
data = text ? JSON.parse(text) : null
|
|
135
|
+
} catch {
|
|
136
|
+
data = text
|
|
137
|
+
}
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
const e = new Error((data && (data.message || data.msg || data.error_description || data.error)) || `HTTP ${res.status}`)
|
|
140
|
+
e.status = res.status
|
|
141
|
+
e.data = data
|
|
142
|
+
throw e
|
|
143
|
+
}
|
|
144
|
+
return data
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Signing in ───────────────────────────────────────────────────────────────
|
|
148
|
+
// The browser does the signing in, on Supabase's page and then GitHub's or Google's; k0 never sees
|
|
149
|
+
// a password. What comes back to k0 is a one-time code on its own address, and k0 trades it for
|
|
150
|
+
// a session using the secret half of a pair it made before sending the browser off (PKCE): a code
|
|
151
|
+
// that leaked on the way back is worth nothing to anybody who does not also hold that half.
|
|
152
|
+
|
|
153
|
+
/** The sign-in started last, waiting for its code. One at a time; an older one is forgotten. */
|
|
154
|
+
let pending = null
|
|
155
|
+
|
|
156
|
+
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
157
|
+
|
|
158
|
+
/** The address to send the browser to. `redirect` is k0's own callback page on this machine. */
|
|
159
|
+
export function loginUrl(provider, redirect) {
|
|
160
|
+
if (!PROVIDERS.includes(provider)) throw new Error(`Sign in with ${PROVIDERS.join(' or ')}.`)
|
|
161
|
+
const verifier = b64url(crypto.randomBytes(48))
|
|
162
|
+
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest())
|
|
163
|
+
pending = { verifier, at: Date.now() }
|
|
164
|
+
const q = new URLSearchParams({
|
|
165
|
+
provider,
|
|
166
|
+
redirect_to: redirect,
|
|
167
|
+
code_challenge: challenge,
|
|
168
|
+
code_challenge_method: 's256',
|
|
169
|
+
})
|
|
170
|
+
return `${base()}/auth/v1/authorize?${q}`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The code came back. Trade it for a session, and find out who this is and whether they are in. */
|
|
174
|
+
export async function finishLogin(code) {
|
|
175
|
+
if (!pending || Date.now() - pending.at > 15 * 60_000) throw new Error('This sign-in has expired. Start it again from the board.')
|
|
176
|
+
const verifier = pending.verifier
|
|
177
|
+
pending = null
|
|
178
|
+
const data = await request('POST', `${base()}/auth/v1/token?grant_type=pkce`, {
|
|
179
|
+
body: { auth_code: String(code), code_verifier: verifier },
|
|
180
|
+
})
|
|
181
|
+
keep(data)
|
|
182
|
+
await refreshMe()
|
|
183
|
+
return status()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function keep(data) {
|
|
187
|
+
const s = load()
|
|
188
|
+
s.session = {
|
|
189
|
+
access_token: data.access_token,
|
|
190
|
+
refresh_token: data.refresh_token,
|
|
191
|
+
expires_at: Number(data.expires_at) || Math.floor(Date.now() / 1000) + Number(data.expires_in || 3600),
|
|
192
|
+
}
|
|
193
|
+
store()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A token good for at least another minute, refreshed first when it is about to run out. */
|
|
197
|
+
async function token() {
|
|
198
|
+
const s = load()
|
|
199
|
+
if (!s.session?.refresh_token) return null
|
|
200
|
+
if (s.session.access_token && s.session.expires_at * 1000 - Date.now() > 60_000) return s.session.access_token
|
|
201
|
+
try {
|
|
202
|
+
const data = await request('POST', `${base()}/auth/v1/token?grant_type=refresh_token`, {
|
|
203
|
+
body: { refresh_token: s.session.refresh_token },
|
|
204
|
+
})
|
|
205
|
+
keep(data)
|
|
206
|
+
return data.access_token
|
|
207
|
+
} catch (err) {
|
|
208
|
+
// Offline is not signed out: the board carries on and asks again later. A refusal is — the
|
|
209
|
+
// session was revoked or has expired for good, and the only way back is to sign in again.
|
|
210
|
+
if (!err.offline && err.status >= 400 && err.status < 500) forget()
|
|
211
|
+
throw err
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function authed(method, pathname, opts = {}) {
|
|
216
|
+
const t = await token()
|
|
217
|
+
if (!t) {
|
|
218
|
+
const e = new Error('Not signed in.')
|
|
219
|
+
e.status = 401
|
|
220
|
+
throw e
|
|
221
|
+
}
|
|
222
|
+
return request(method, `${base()}${pathname}`, { ...opts, token: t })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function forget() {
|
|
226
|
+
const s = load()
|
|
227
|
+
delete s.session
|
|
228
|
+
delete s.me
|
|
229
|
+
delete s.pulled
|
|
230
|
+
store()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Who this is, whether they have been let in, and where their rows go. */
|
|
234
|
+
export async function refreshMe() {
|
|
235
|
+
const me = await authed('POST', '/rest/v1/rpc/k0_me', { body: {} })
|
|
236
|
+
const s = load()
|
|
237
|
+
s.me = me || null
|
|
238
|
+
store()
|
|
239
|
+
return s.me
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function logout() {
|
|
243
|
+
const s = load()
|
|
244
|
+
const t = s.session?.access_token
|
|
245
|
+
forget()
|
|
246
|
+
if (t) {
|
|
247
|
+
try {
|
|
248
|
+
await request('POST', `${base()}/auth/v1/logout`, { token: t, body: {} })
|
|
249
|
+
} catch {
|
|
250
|
+
// Signed out here is signed out. The cloud lets the token expire on its own.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return status()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── What the board shows about it ────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
export const linked = () => enabled() && !!load().session?.refresh_token
|
|
259
|
+
export const me = () => (linked() ? load().me || null : null)
|
|
260
|
+
export const approved = () => !!me()?.approved
|
|
261
|
+
export const workspace = () => me()?.workspace || null
|
|
262
|
+
|
|
263
|
+
export function status() {
|
|
264
|
+
const m = me()
|
|
265
|
+
return {
|
|
266
|
+
enabled: enabled(),
|
|
267
|
+
linked: linked(),
|
|
268
|
+
email: m?.email ?? null,
|
|
269
|
+
name: m?.name ?? null,
|
|
270
|
+
avatar_url: m?.avatar_url ?? null,
|
|
271
|
+
approved: !!m?.approved,
|
|
272
|
+
is_admin: !!m?.is_admin,
|
|
273
|
+
online,
|
|
274
|
+
error: lastError,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Which ways in are switched on in the cloud. Asked rarely: it changes when somebody sets one up. */
|
|
279
|
+
let providersSeen = null
|
|
280
|
+
export async function providers() {
|
|
281
|
+
if (providersSeen && Date.now() - providersSeen.at < 10 * 60_000) return providersSeen.list
|
|
282
|
+
try {
|
|
283
|
+
const data = await request('GET', `${base()}/auth/v1/settings`)
|
|
284
|
+
const list = PROVIDERS.filter((p) => data?.external?.[p])
|
|
285
|
+
providersSeen = { list, at: Date.now() }
|
|
286
|
+
return list
|
|
287
|
+
} catch {
|
|
288
|
+
return providersSeen?.list ?? null
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── The next number ──────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The next K number for this account, or null when it cannot be had right now — not signed in,
|
|
296
|
+
* not let in, or no answer within a couple of seconds. Null is not an error: the story is made
|
|
297
|
+
* with a provisional key and the sync gives it its real one later.
|
|
298
|
+
*/
|
|
299
|
+
export async function nextKey() {
|
|
300
|
+
if (!linked() || !approved() || !workspace()) return null
|
|
301
|
+
try {
|
|
302
|
+
const n = await authed('POST', '/rest/v1/rpc/k0_next_key', { body: { ws: workspace() }, timeout: KEY_TIMEOUT })
|
|
303
|
+
return Number.isInteger(n) && n > 0 ? n : null
|
|
304
|
+
} catch {
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Moves the cloud's counter past numbers this machine already holds. */
|
|
310
|
+
export async function reserveKeys(upto) {
|
|
311
|
+
return authed('POST', '/rest/v1/rpc/k0_reserve_keys', { body: { ws: workspace(), upto } })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Rows ─────────────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/** Sends rows. Each is `{ kind, uid, data, deleted, changed_at }`. */
|
|
317
|
+
export async function push(rows) {
|
|
318
|
+
if (!rows.length) return 0
|
|
319
|
+
const ws = workspace()
|
|
320
|
+
const body = rows.map((r) => ({
|
|
321
|
+
workspace_id: ws,
|
|
322
|
+
kind: r.kind,
|
|
323
|
+
uid: r.uid,
|
|
324
|
+
data: r.data ?? {},
|
|
325
|
+
deleted: !!r.deleted,
|
|
326
|
+
changed_at: r.changed_at ?? Date.now(),
|
|
327
|
+
machine: machine(),
|
|
328
|
+
}))
|
|
329
|
+
await authed('POST', '/rest/v1/records?on_conflict=workspace_id,kind,uid', {
|
|
330
|
+
body,
|
|
331
|
+
prefer: 'resolution=merge-duplicates,return=minimal',
|
|
332
|
+
})
|
|
333
|
+
return body.length
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** What other machines wrote since `since`, oldest first. */
|
|
337
|
+
export async function pull(since = 0, limit = 500) {
|
|
338
|
+
const q = new URLSearchParams({
|
|
339
|
+
select: 'kind,uid,data,deleted,changed_at,seq',
|
|
340
|
+
workspace_id: `eq.${workspace()}`,
|
|
341
|
+
seq: `gt.${Number(since) || 0}`,
|
|
342
|
+
machine: `neq.${machine()}`,
|
|
343
|
+
order: 'seq.asc',
|
|
344
|
+
limit: String(limit),
|
|
345
|
+
})
|
|
346
|
+
return (await authed('GET', `/rest/v1/records?${q}`)) || []
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** How far this machine has read. Kept with the sign-in, because it means nothing without it. */
|
|
350
|
+
export const pulledTo = () => Number(load().pulled) || 0
|
|
351
|
+
export function setPulledTo(seq) {
|
|
352
|
+
const s = load()
|
|
353
|
+
s.pulled = Number(seq) || 0
|
|
354
|
+
store()
|
|
355
|
+
}
|