@rubytech/create-maxy-code 0.1.488 → 0.1.489
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/package.json +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-22-task-1899-reconcile-report-only.md +748 -0
- package/payload/platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md +121 -0
- package/payload/platform/lib/active-rules/package-lock.json +12 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js +51 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts +2 -2
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js +20 -4
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js.map +1 -1
- package/payload/platform/lib/graph-mcp/src/__tests__/schema-cypher-parser.test.ts +55 -0
- package/payload/platform/lib/graph-mcp/src/schema-cypher-parser.ts +19 -4
- package/payload/platform/lib/graph-style/dist/index.d.ts +24 -0
- package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-style/dist/index.js +25 -1
- package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-style/src/__tests__/additional-base-labels.test.ts +22 -0
- package/payload/platform/lib/graph-style/src/index.ts +27 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +15 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/audit.d.ts +5 -0
- package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/audit.js +3 -2
- package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +21 -0
- package/payload/platform/lib/storage-broker/src/audit.ts +8 -2
- package/payload/platform/neo4j/schema.cypher +105 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +8 -2
- package/payload/platform/plugins/cloudflare/PLUGIN.md +22 -2
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +30 -8
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +75 -13
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +77 -9
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +38 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +72 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +43 -7
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +22 -7
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +14 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +23 -5
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +3 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +46 -5
- package/payload/platform/plugins/docs/references/calendar-booking.md +5 -1
- package/payload/platform/plugins/docs/references/internals.md +2 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js +61 -31
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
- package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +70 -5
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js +31 -6
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +31 -4
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +18 -7
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts +15 -6
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js +24 -11
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +84 -5
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
- package/payload/platform/scripts/__tests__/quarantine-restore.test.sh +143 -0
- package/payload/platform/scripts/quarantine-restore.mjs +175 -0
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +24 -7
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +52 -74
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +11 -8
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +32 -0
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.js +32 -2
- package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +6 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.js +6 -0
- package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
- package/payload/platform/templates/account-schema/SCHEMA.md +7 -4
- package/payload/server/{chunk-REWXLAPC.js → chunk-UFXRUKXO.js} +4 -2
- package/payload/server/public/assets/{admin-CThbXaYH.js → admin-DvQPTxqe.js} +1 -1
- package/payload/server/public/assets/{data-D5dp6nj6.js → data-Bu-5SUmR.js} +1 -1
- package/payload/server/public/assets/{graph-CNgt7ygi.js → graph-Cyh6FWaV.js} +1 -1
- package/payload/server/public/assets/graph-labels-CUQtXFKW.js +1 -0
- package/payload/server/public/assets/{page-Bvx0A2jB.js → page-Cno8NNMk.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +2 -2
- package/payload/server/public/index.html +3 -3
- package/payload/server/server.js +1032 -787
- package/payload/server/{src-JJ6PHO5I.js → src-YJF3UJNR.js} +1 -1
- package/payload/server/public/assets/graph-labels-DA48HrRR.js +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, unlinkSync, utimesSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { walkExposed, pushAccount } from '../../bin/portal-index-push.mjs'
|
|
@@ -12,6 +12,10 @@ function file(rel: string, body = 'x') {
|
|
|
12
12
|
writeFileSync(abs, body)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function emptyDir(rel: string) {
|
|
16
|
+
mkdirSync(join(dir, rel), { recursive: true })
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
const SCHEMA = [
|
|
16
20
|
'```allowed-top-level',
|
|
17
21
|
'output',
|
|
@@ -71,8 +75,11 @@ const push = (client: unknown, overrides: Record<string, unknown> = {}) =>
|
|
|
71
75
|
})
|
|
72
76
|
|
|
73
77
|
describe('walkExposed', () => {
|
|
74
|
-
it('returns files
|
|
75
|
-
const paths = (await walkExposed(dir, ['output', 'quotes']))
|
|
78
|
+
it('returns files from exposed dirs, at any depth', async () => {
|
|
79
|
+
const paths = (await walkExposed(dir, ['output', 'quotes']))
|
|
80
|
+
.filter((r) => !r.isDir)
|
|
81
|
+
.map((r) => r.relPath)
|
|
82
|
+
.sort()
|
|
76
83
|
expect(paths).toEqual(['output/report.pdf', 'quotes/CR2969/quote.pdf', 'quotes/CR2974/quote.pdf'])
|
|
77
84
|
})
|
|
78
85
|
|
|
@@ -89,29 +96,90 @@ describe('walkExposed', () => {
|
|
|
89
96
|
})
|
|
90
97
|
|
|
91
98
|
it('carries size and an iso modified time', async () => {
|
|
92
|
-
const row = (await walkExposed(dir, ['output']))
|
|
99
|
+
const row = (await walkExposed(dir, ['output'])).find((r) => !r.isDir)!
|
|
93
100
|
expect(row.sizeBytes).toBe(1)
|
|
94
101
|
expect(row.modifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
95
102
|
})
|
|
103
|
+
|
|
104
|
+
// An exposed folder holding no files at any depth is the case the client
|
|
105
|
+
// could not see at all: nothing derived a folder name from a file prefix.
|
|
106
|
+
it('emits exactly one row for an exposed dir holding no files, marked isDir', async () => {
|
|
107
|
+
emptyDir('jobs')
|
|
108
|
+
const rows = await walkExposed(dir, ['jobs'])
|
|
109
|
+
expect(rows.length).toBe(1)
|
|
110
|
+
expect(rows[0].relPath).toBe('jobs')
|
|
111
|
+
expect(rows[0].isDir).toBe(true)
|
|
112
|
+
expect(rows[0].sizeBytes).toBe(0)
|
|
113
|
+
expect(rows[0].modifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('emits the dir row and the file row for a dir holding a nested file', async () => {
|
|
117
|
+
const rows = await walkExposed(dir, ['quotes'])
|
|
118
|
+
const byPath = new Map(rows.map((r) => [r.relPath, r]))
|
|
119
|
+
expect(byPath.get('quotes')?.isDir).toBe(true)
|
|
120
|
+
expect(byPath.get('quotes/CR2969')?.isDir).toBe(true)
|
|
121
|
+
expect(byPath.get('quotes/CR2969/quote.pdf')?.isDir).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// The dir row's time is the directory's own, not the walk's: a folder whose
|
|
125
|
+
// contents changed is what the operator reads, and `new Date()` here would
|
|
126
|
+
// make every row look freshly touched every cycle.
|
|
127
|
+
it('takes the dir mtime rather than the time of the walk', async () => {
|
|
128
|
+
emptyDir('jobs')
|
|
129
|
+
const past = new Date('2020-03-04T05:06:07.000Z')
|
|
130
|
+
utimesSync(join(dir, 'jobs'), past, past)
|
|
131
|
+
const rows = await walkExposed(dir, ['jobs'])
|
|
132
|
+
expect(rows[0].modifiedAt).toBe('2020-03-04T05:06:07.000Z')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('emits no row for a dot-prefixed dir at any level', async () => {
|
|
136
|
+
const paths = (await walkExposed(dir, ['output'])).map((r) => r.relPath)
|
|
137
|
+
expect(paths).not.toContain('output/.uploads-tmp')
|
|
138
|
+
})
|
|
96
139
|
})
|
|
97
140
|
|
|
98
141
|
describe('pushAccount', () => {
|
|
142
|
+
// 7 rows: the 3 files plus output/, quotes/, quotes/CR2969/, quotes/CR2974/.
|
|
99
143
|
it('deletes the account rows then inserts the current walk', async () => {
|
|
100
144
|
const c = fakeClient()
|
|
101
145
|
const r = await push(c)
|
|
102
146
|
expect(r.exposed).toEqual(['output', 'quotes'])
|
|
103
|
-
expect(r.rows).toBe(
|
|
147
|
+
expect(r.rows).toBe(7)
|
|
104
148
|
expect(/DELETE FROM directory/i.test(c.calls[0].sql)).toBe(true)
|
|
105
149
|
expect(c.calls[0].params).toEqual(['acc-1'])
|
|
106
|
-
expect(c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).length).toBe(
|
|
150
|
+
expect(c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).length).toBe(7)
|
|
107
151
|
})
|
|
108
152
|
|
|
153
|
+
// The folder itself survives its last file: emptied is not the same as gone,
|
|
154
|
+
// and the client still needs somewhere to put the next one.
|
|
109
155
|
it('converges on a delete: a removed file leaves no stale row', async () => {
|
|
110
156
|
const c = fakeClient()
|
|
111
157
|
await push(c)
|
|
112
158
|
unlinkSync(join(dir, 'quotes/CR2974/quote.pdf'))
|
|
159
|
+
// The fake accumulates calls across pushes; only the second one is the
|
|
160
|
+
// converged state.
|
|
161
|
+
const before = c.calls.length
|
|
113
162
|
const second = await push(c)
|
|
114
|
-
expect(second.rows).toBe(
|
|
163
|
+
expect(second.rows).toBe(6)
|
|
164
|
+
const inserted = c.calls
|
|
165
|
+
.slice(before)
|
|
166
|
+
.filter((x) => /INSERT INTO directory/i.test(x.sql))
|
|
167
|
+
.map((x) => String(x.params[1]))
|
|
168
|
+
expect(inserted).toContain('quotes/CR2974')
|
|
169
|
+
expect(inserted).not.toContain('quotes/CR2974/quote.pdf')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('marks each inserted row as a directory or not', async () => {
|
|
173
|
+
const c = fakeClient()
|
|
174
|
+
await push(c)
|
|
175
|
+
const byPath = new Map(
|
|
176
|
+
c.calls
|
|
177
|
+
.filter((x) => /INSERT INTO directory/i.test(x.sql))
|
|
178
|
+
.map((x) => [String(x.params[1]), x.params[5]]),
|
|
179
|
+
)
|
|
180
|
+
expect(byPath.get('quotes')).toBe(1)
|
|
181
|
+
expect(byPath.get('quotes/CR2969')).toBe(1)
|
|
182
|
+
expect(byPath.get('quotes/CR2969/quote.pdf')).toBe(0)
|
|
115
183
|
})
|
|
116
184
|
|
|
117
185
|
it('binds every value as a param rather than interpolating it', async () => {
|
|
@@ -138,8 +206,8 @@ describe('pushAccount', () => {
|
|
|
138
206
|
const c = fakeClient()
|
|
139
207
|
await push(c, { log: (l: string) => lines.push(l) })
|
|
140
208
|
expect(lines.some((l) => l.includes('op=resolve') && l.includes('exposed=output,quotes'))).toBe(true)
|
|
141
|
-
expect(lines.some((l) => l.includes('op=replace') && l.includes('inserted=
|
|
142
|
-
expect(lines.some((l) => l.includes('op=verify') && l.includes('rowsAfter=
|
|
209
|
+
expect(lines.some((l) => l.includes('op=replace') && l.includes('inserted=7'))).toBe(true)
|
|
210
|
+
expect(lines.some((l) => l.includes('op=verify') && l.includes('rowsAfter=7') && l.includes('expected=7'))).toBe(true)
|
|
143
211
|
})
|
|
144
212
|
|
|
145
213
|
it('reports an exposed-set change only when it actually changes', async () => {
|
|
@@ -10,14 +10,17 @@ function makeEnv(
|
|
|
10
10
|
secret?: string
|
|
11
11
|
origin?: string
|
|
12
12
|
paths?: string[]
|
|
13
|
+
dirPaths?: string[]
|
|
13
14
|
folders?: string
|
|
15
|
+
onQuery?: (query: string) => void
|
|
14
16
|
} = {},
|
|
15
17
|
) {
|
|
16
|
-
const { accountId = 'acc-a', paths = [PATH] } = opts
|
|
18
|
+
const { accountId = 'acc-a', paths = [PATH], dirPaths = [], onQuery = () => {} } = opts
|
|
17
19
|
const folders = 'folders' in opts ? opts.folders : ''
|
|
18
20
|
return {
|
|
19
21
|
DB: {
|
|
20
22
|
prepare(query: string) {
|
|
23
|
+
onQuery(query)
|
|
21
24
|
let bound: unknown[] = []
|
|
22
25
|
const stmt = {
|
|
23
26
|
bind(...v: unknown[]) {
|
|
@@ -37,7 +40,9 @@ function makeEnv(
|
|
|
37
40
|
: null) as T | null
|
|
38
41
|
}
|
|
39
42
|
if (/FROM directory/i.test(query)) {
|
|
40
|
-
|
|
43
|
+
const rel = bound[1] as string
|
|
44
|
+
if (dirPaths.includes(rel)) return { relPath: rel, isDir: 1 } as T
|
|
45
|
+
return (paths.includes(rel) ? { relPath: rel, isDir: 0 } : null) as T | null
|
|
41
46
|
}
|
|
42
47
|
return null as T | null
|
|
43
48
|
},
|
|
@@ -100,6 +105,37 @@ describe('processIndexedDownload', () => {
|
|
|
100
105
|
expect(touched).toBe(false)
|
|
101
106
|
})
|
|
102
107
|
|
|
108
|
+
// Folders are in the index now, so a client can name one here. A folder has
|
|
109
|
+
// no bytes and must never mint a signed link. result=is-directory separates
|
|
110
|
+
// it from result=not-granted and result=absent, the only readings before.
|
|
111
|
+
it('refuses a directory rather than signing a link for it', async () => {
|
|
112
|
+
let touched = false
|
|
113
|
+
const spy = async () => {
|
|
114
|
+
touched = true
|
|
115
|
+
return { ok: true, status: 200 }
|
|
116
|
+
}
|
|
117
|
+
const lines: string[] = []
|
|
118
|
+
const r = await processIndexedDownload(
|
|
119
|
+
'sess-a',
|
|
120
|
+
'quotes/CR2969',
|
|
121
|
+
makeEnv({ paths: [PATH], dirPaths: ['quotes/CR2969'] }),
|
|
122
|
+
(l) => lines.push(l),
|
|
123
|
+
1000,
|
|
124
|
+
spy as never,
|
|
125
|
+
)
|
|
126
|
+
expect(r.status).toBe(404)
|
|
127
|
+
expect(r.url).toBeUndefined()
|
|
128
|
+
expect(touched).toBe(false)
|
|
129
|
+
expect(lines.some((l) => l.includes('result=is-directory'))).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('reads isDir in the directory select', async () => {
|
|
133
|
+
const queries: string[] = []
|
|
134
|
+
const env = makeEnv({ onQuery: (q) => queries.push(q) })
|
|
135
|
+
await processIndexedDownload('sess-a', PATH, env, () => {}, 1000, okHead as never)
|
|
136
|
+
expect(queries.some((q) => /FROM directory/i.test(q) && /isDir/.test(q))).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
|
|
103
139
|
it('401s without a session', async () => {
|
|
104
140
|
const r = await processIndexedDownload('nope', PATH, makeEnv(), () => {}, 1000, okHead as never)
|
|
105
141
|
expect(r.status).toBe(401)
|
|
@@ -143,6 +143,16 @@ describe('device binding', () => {
|
|
|
143
143
|
expect(text).toMatch(/UNIQUE \(accountId, relPath\)/)
|
|
144
144
|
expect(text).toMatch(/accountId\s+TEXT NOT NULL DEFAULT ''/)
|
|
145
145
|
})
|
|
146
|
+
|
|
147
|
+
// IF NOT EXISTS is a no-op on an existing table, so a portal deployed before
|
|
148
|
+
// this column will NOT gain it from a re-apply. The push names isDir in its
|
|
149
|
+
// INSERT, so such a table fails loudly rather than silently publishing
|
|
150
|
+
// nothing — the ALTER has to be findable at the point that happens.
|
|
151
|
+
it('declares isDir and carries the ALTER for a table that predates it', () => {
|
|
152
|
+
const text = schema()
|
|
153
|
+
expect(text).toMatch(/isDir\s+INTEGER NOT NULL DEFAULT 0/)
|
|
154
|
+
expect(text).toContain('ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0')
|
|
155
|
+
})
|
|
146
156
|
})
|
|
147
157
|
|
|
148
158
|
const portalJs = () => readFileSync(join(TEMPLATE, 'portal.js'), 'utf8')
|
|
@@ -170,3 +180,65 @@ describe('login error surfacing and passcode reveal (Task 1872)', () => {
|
|
|
170
180
|
expect(portalJs()).toMatch(/dp-reveal/)
|
|
171
181
|
})
|
|
172
182
|
})
|
|
183
|
+
|
|
184
|
+
// Task 1900. The device pushes a new tree hourly, so a client who stays signed
|
|
185
|
+
// in reads an index frozen at sign-in. These assertions pin the control that
|
|
186
|
+
// re-reads it: the button in the pinned header, and the click that drives it.
|
|
187
|
+
describe('refresh control', () => {
|
|
188
|
+
it('puts a refresh button inside the pinned header', () => {
|
|
189
|
+
const header = indexHtml().match(/<header class="dp-header">[\s\S]*?<\/header>/)
|
|
190
|
+
expect(header).not.toBeNull()
|
|
191
|
+
expect(header![0]).toMatch(/id="dp-refresh"/)
|
|
192
|
+
expect(header![0]).toMatch(/class="dp-iconbtn"/)
|
|
193
|
+
expect(header![0]).toMatch(/aria-label="Refresh"/)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('sits between the crumbs and the upload control', () => {
|
|
197
|
+
const text = indexHtml()
|
|
198
|
+
expect(text.indexOf('id="dp-crumbs"')).toBeLessThan(text.indexOf('id="dp-refresh"'))
|
|
199
|
+
expect(text.indexOf('id="dp-refresh"')).toBeLessThan(text.indexOf('id="dp-upload-open"'))
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('binds a click on it', () => {
|
|
203
|
+
expect(portalJs()).toMatch(/refreshEl\.addEventListener\('click'/)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('drives refresh() rather than reloading the page', () => {
|
|
207
|
+
expect(portalJs()).not.toMatch(/location\.reload/)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// The status line has to survive a failed re-read. refresh() catches its own
|
|
211
|
+
// failure, so the promise resolves either way; only a reported outcome tells
|
|
212
|
+
// the handler whether clearing the status would erase an error.
|
|
213
|
+
it('has refresh() report its outcome on all three exits', () => {
|
|
214
|
+
const body = portalJs().match(/function refresh\(\)[\s\S]*?\n }\n/)
|
|
215
|
+
expect(body).not.toBeNull()
|
|
216
|
+
expect(body![0]).toMatch(/return true/)
|
|
217
|
+
expect((body![0].match(/return false/g) || []).length).toBe(2)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// No timer, no focus handler. A poll against an hourly tree is waste.
|
|
221
|
+
it('never polls', () => {
|
|
222
|
+
expect(portalJs()).not.toMatch(/setInterval|visibilitychange/)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// refresh() now writes its own failure text, which would bury the outcome of
|
|
227
|
+
// the delete or upload the client just asked for. Both callers compose instead,
|
|
228
|
+
// so a stale list is reported without losing what happened to the file.
|
|
229
|
+
describe('an action that outlives its re-list', () => {
|
|
230
|
+
it('keeps the delete and upload outcome when the re-list fails', () => {
|
|
231
|
+
const text = portalJs()
|
|
232
|
+
expect((text.match(/The list could not be reloaded\./g) || []).length).toBe(2)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('has both callers read the boolean refresh() returns', () => {
|
|
236
|
+
const text = portalJs()
|
|
237
|
+
const removeBody = text.match(/function remove\(key\)[\s\S]*?\n }\n/)
|
|
238
|
+
const uploadBody = text.match(/function uploadFile\(file\)[\s\S]*?\n }\n/)
|
|
239
|
+
expect(removeBody).not.toBeNull()
|
|
240
|
+
expect(uploadBody).not.toBeNull()
|
|
241
|
+
expect(removeBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/)
|
|
242
|
+
expect(uploadBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
@@ -13,9 +13,11 @@ references with `plugin-read` before issuing their commands.
|
|
|
13
13
|
## What this skill produces, and what it does not
|
|
14
14
|
|
|
15
15
|
It produces a Pages project on a custom domain, an R2 bucket holding one prefix per enrolled person,
|
|
16
|
-
and a D1 database holding the manifest, the enrolment records, and the sessions. It does not
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
and a D1 database holding the manifest, the enrolment records, and the sessions. It does not carry a
|
|
17
|
+
dropped file onto the device: the `portal-uploads-pull` loop in the admin server does that, every
|
|
18
|
+
minute, writing each un-ingested manifest row's object to `uploads/<ownerName>/` and setting
|
|
19
|
+
`ingested = 1` only once the bytes are on disk at the right size. This skill delivers the manifest
|
|
20
|
+
that loop consumes. Nothing here or there ingests a pulled file into the graph.
|
|
19
21
|
|
|
20
22
|
## Why the portal is on Pages and not the install
|
|
21
23
|
|
|
@@ -110,6 +112,17 @@ with the same `bin/cf-store-name.sh` scheme as the bucket, and apply
|
|
|
110
112
|
`platform/plugins/cloudflare/skills/data-portal/schema.sql` to the remote database from there. It is
|
|
111
113
|
`IF NOT EXISTS` throughout, so re-applying is safe.
|
|
112
114
|
|
|
115
|
+
Safe, but not a migration: `IF NOT EXISTS` adds no column to a table that already exists. A portal
|
|
116
|
+
whose `directory` table predates the `isDir` column keeps failing every index push, because the push
|
|
117
|
+
names the column in its `INSERT`. Add it once by hand against that database:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npx wrangler d1 execute <portalDbName> --remote \
|
|
121
|
+
--command "ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
No backfill follows it. The push replaces every row for the account each cycle.
|
|
125
|
+
|
|
113
126
|
The copied `portal.css` carries the template's default palette, which is nobody's brand. Apply the
|
|
114
127
|
install's before handing the tree to the deploy:
|
|
115
128
|
|
|
@@ -164,7 +177,7 @@ anyway. That check is what contains a stale index or a compromised project, and
|
|
|
164
177
|
|
|
165
178
|
## Publish the folder index
|
|
166
179
|
|
|
167
|
-
The device publishes file metadata — names, sizes, times, never bytes:
|
|
180
|
+
The device publishes file and folder metadata — names, sizes, times, never bytes:
|
|
168
181
|
|
|
169
182
|
```bash
|
|
170
183
|
node platform/plugins/cloudflare/bin/portal-index-push.mjs \
|
|
@@ -178,6 +191,13 @@ brand with a different vertical publishes different folders with no change to th
|
|
|
178
191
|
data (`documents/`, `contacts/`, `projects/`), the client's own `uploads/`, plugin workspaces and
|
|
179
192
|
everything platform-internal are in neither source and are never published.
|
|
180
193
|
|
|
194
|
+
Every walked folder gets a row of its own, not only the files inside it. An exposed folder holding
|
|
195
|
+
nothing at any depth is therefore a real entry: it renders in the tree and opens to an empty listing,
|
|
196
|
+
rather than reading as absent to the person who has to put the first file into it. A folder row
|
|
197
|
+
carries no bytes, so the download path refuses it and logs `result=is-directory`. A folder named in
|
|
198
|
+
the schema but absent from the device's disk still publishes nothing, so the tree never offers a
|
|
199
|
+
folder the device does not have.
|
|
200
|
+
|
|
181
201
|
An account with no `SCHEMA.md` publishes nothing at all. That is deliberate and it is the opposite of
|
|
182
202
|
what the write-path guard does — the guard fails open so an unseeded account is not write-blocked,
|
|
183
203
|
while a surface facing a client must fail closed.
|
|
@@ -208,9 +228,25 @@ operator requested in `exposeFolders`; a non-zero `extraFolders` with none of th
|
|
|
208
228
|
|
|
209
229
|
Signed in, the panel has a pinned header that stays put while the file lists
|
|
210
230
|
scroll under it. The header carries the folder breadcrumbs, an up control that
|
|
211
|
-
moves to the parent folder (disabled at the root),
|
|
212
|
-
render on one line: the stem truncates with an ellipsis
|
|
213
|
-
pinned, so `.pdf` and `.html` on two same-named orders
|
|
231
|
+
moves to the parent folder (disabled at the root), a refresh control, and an
|
|
232
|
+
upload icon. Filenames render on one line: the stem truncates with an ellipsis
|
|
233
|
+
and the extension stays pinned, so `.pdf` and `.html` on two same-named orders
|
|
234
|
+
are still told apart.
|
|
235
|
+
|
|
236
|
+
Refresh re-reads the index in the folder that is already open, so a client who
|
|
237
|
+
stays signed in sees files the device pushed after they arrived without
|
|
238
|
+
reloading the page. It is a click and never a timer: the device pushes hourly,
|
|
239
|
+
so polling would be waste. The button is inert while its request is in flight,
|
|
240
|
+
and the status line clears on success or keeps the failure message on screen.
|
|
241
|
+
Each click is one further `op=list` line carrying the same account and prefix,
|
|
242
|
+
so a click with no line is the handler not firing and two lines is the in-flight
|
|
243
|
+
guard not holding.
|
|
244
|
+
|
|
245
|
+
Delete and upload both re-read the list when they finish. If that re-read
|
|
246
|
+
fails, the status keeps the outcome and adds to it, so a client reads
|
|
247
|
+
`Deleted. The list could not be reloaded.` rather than a bare load error that
|
|
248
|
+
would leave them unsure whether the file went. The rows on screen are then
|
|
249
|
+
known to be stale, and refresh is how they clear that.
|
|
214
250
|
|
|
215
251
|
A shared (device-held) file downloads three ways, all through the same signed
|
|
216
252
|
`/api/download?path=` route: a left click, a right-click that opens a one-item
|
|
@@ -58,9 +58,10 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
58
58
|
|
|
59
59
|
CREATE INDEX IF NOT EXISTS sessions_owner ON sessions (ownerId);
|
|
60
60
|
|
|
61
|
-
-- One row per stored object. `ingested` is the claim flag the
|
|
62
|
-
--
|
|
63
|
-
--
|
|
61
|
+
-- One row per stored object. `ingested` is the claim flag the admin server's
|
|
62
|
+
-- `portal-uploads-pull` loop reads and sets; a row stays 0 until its object is
|
|
63
|
+
-- on the device at the size recorded here, so the backlog survives the device
|
|
64
|
+
-- being offline and a half-finished transfer never reads as delivered.
|
|
64
65
|
CREATE TABLE IF NOT EXISTS manifest (
|
|
65
66
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
66
67
|
fileId TEXT UNIQUE NOT NULL,
|
|
@@ -88,16 +89,29 @@ CREATE TABLE IF NOT EXISTS auth_attempts (
|
|
|
88
89
|
|
|
89
90
|
CREATE UNIQUE INDEX IF NOT EXISTS auth_attempts_scope_window ON auth_attempts (scope, windowStart);
|
|
90
91
|
|
|
91
|
-
-- One row per file the device has published to this portal. Metadata
|
|
92
|
-
-- bytes stay on the device and are fetched on demand through a
|
|
93
|
-
-- signed link, which is what lets the folder tree render while the
|
|
94
|
-
-- offline.
|
|
92
|
+
-- One row per file OR folder the device has published to this portal. Metadata
|
|
93
|
+
-- only: the bytes stay on the device and are fetched on demand through a
|
|
94
|
+
-- short-lived signed link, which is what lets the folder tree render while the
|
|
95
|
+
-- device is offline.
|
|
95
96
|
--
|
|
96
97
|
-- Written by a full replace per account (delete-all, then insert the current
|
|
97
98
|
-- walk), which is why there is no tombstone column: a file deleted or renamed on
|
|
98
99
|
-- the device simply stops being inserted on the next cycle. The UNIQUE key is
|
|
99
100
|
-- what makes that replace safe to retry — a double insert of the same path
|
|
100
101
|
-- fails loudly instead of silently doubling the row.
|
|
102
|
+
--
|
|
103
|
+
-- `isDir` marks a folder row. Folders are stored rather than derived from file
|
|
104
|
+
-- path prefixes, because a folder holding no files at any depth has no prefix
|
|
105
|
+
-- to derive it from and would not exist to the client at all. A folder row
|
|
106
|
+
-- carries sizeBytes 0 and the folder's own modified time, and the download path
|
|
107
|
+
-- refuses to mint a signed link for one.
|
|
108
|
+
--
|
|
109
|
+
-- Like `people.accountId` and `people.folders`, IF NOT EXISTS will NOT add this
|
|
110
|
+
-- column to a `directory` table that predates it, and the push names `isDir` in
|
|
111
|
+
-- its INSERT, so such a table fails loudly on the next cycle. Add it by hand in
|
|
112
|
+
-- that case:
|
|
113
|
+
-- ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0;
|
|
114
|
+
-- No backfill follows it: the push full-replaces every row each cycle.
|
|
101
115
|
CREATE TABLE IF NOT EXISTS directory (
|
|
102
116
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
103
117
|
accountId TEXT NOT NULL,
|
|
@@ -105,6 +119,7 @@ CREATE TABLE IF NOT EXISTS directory (
|
|
|
105
119
|
sizeBytes INTEGER NOT NULL,
|
|
106
120
|
modifiedAt TEXT NOT NULL,
|
|
107
121
|
indexedAt TEXT NOT NULL,
|
|
122
|
+
isDir INTEGER NOT NULL DEFAULT 0,
|
|
108
123
|
UNIQUE (accountId, relPath)
|
|
109
124
|
);
|
|
110
125
|
|
package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts
CHANGED
|
@@ -83,15 +83,27 @@ export async function processIndexedDownload(
|
|
|
83
83
|
return { status: 404, payload: { ok: false, error: 'not found' } }
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
const row = await env.DB.prepare(
|
|
86
|
+
const row = await env.DB.prepare(
|
|
87
|
+
'SELECT relPath, isDir FROM directory WHERE accountId = ? AND relPath = ?',
|
|
88
|
+
)
|
|
87
89
|
.bind(session.accountId, relPath)
|
|
88
|
-
.first<{ relPath: string }>()
|
|
90
|
+
.first<{ relPath: string; isDir: number }>()
|
|
89
91
|
if (!row) {
|
|
90
92
|
log(
|
|
91
93
|
`[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} result=absent`,
|
|
92
94
|
)
|
|
93
95
|
return { status: 404, payload: { ok: false, error: 'not found' } }
|
|
94
96
|
}
|
|
97
|
+
if (row.isDir) {
|
|
98
|
+
// Folders are in the index, so a client can name one here. A folder has no
|
|
99
|
+
// bytes: minting a link would send the device a path it cannot serve, and
|
|
100
|
+
// the browser an opaque failure. Refused before the probe, and reported
|
|
101
|
+
// distinguishably from not-granted and absent.
|
|
102
|
+
log(
|
|
103
|
+
`[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} result=is-directory`,
|
|
104
|
+
)
|
|
105
|
+
return { status: 404, payload: { ok: false, error: 'not found' } }
|
|
106
|
+
}
|
|
95
107
|
|
|
96
108
|
const secret = env.PORTAL_FETCH_SECRET
|
|
97
109
|
const origin = env.PORTAL_INSTALL_ORIGIN
|
package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts
CHANGED
|
@@ -6,6 +6,7 @@ interface DirRow {
|
|
|
6
6
|
relPath: string
|
|
7
7
|
sizeBytes: number
|
|
8
8
|
modifiedAt: string
|
|
9
|
+
isDir: number
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export interface DirEntry {
|
|
@@ -19,10 +20,12 @@ export interface DirEntry {
|
|
|
19
20
|
/**
|
|
20
21
|
* Collapse the flat `directory` rows into one level of listing under `prefix`.
|
|
21
22
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* The device publishes a row per folder as well as per file, so a folder
|
|
24
|
+
* holding no files at any depth is a real entry that opens and reads as empty.
|
|
25
|
+
* Folders are ALSO derived from the path segments of deeper rows, and the two
|
|
26
|
+
* sources collapse onto one entry per name. The prefix matches on a whole
|
|
27
|
+
* segment (`quotes/`, not `quotes`), so a partial name can never reach a
|
|
28
|
+
* sibling folder's contents.
|
|
26
29
|
*/
|
|
27
30
|
export function entriesUnder(rows: DirRow[], prefix: string): DirEntry[] {
|
|
28
31
|
const base = prefix ? `${prefix}/` : ''
|
|
@@ -34,6 +37,21 @@ export function entriesUnder(rows: DirRow[], prefix: string): DirEntry[] {
|
|
|
34
37
|
if (!rest) continue
|
|
35
38
|
const slash = rest.indexOf('/')
|
|
36
39
|
if (slash === -1) {
|
|
40
|
+
// A folder's own row has nothing left after the prefix, exactly like a
|
|
41
|
+
// file's. `isDir` is the only thing separating them; without it a folder
|
|
42
|
+
// lands in the file list and a click tries to download it.
|
|
43
|
+
if (row.isDir) {
|
|
44
|
+
if (!dirs.has(rest)) {
|
|
45
|
+
dirs.set(rest, {
|
|
46
|
+
name: rest,
|
|
47
|
+
kind: 'directory',
|
|
48
|
+
relPath: row.relPath,
|
|
49
|
+
sizeBytes: null,
|
|
50
|
+
modifiedAt: null,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
37
55
|
files.push({
|
|
38
56
|
name: rest,
|
|
39
57
|
kind: 'file',
|
|
@@ -89,7 +107,7 @@ export async function processFiles(
|
|
|
89
107
|
const grant = session.folders
|
|
90
108
|
if (session.accountId) {
|
|
91
109
|
const dir = await env.DB.prepare(
|
|
92
|
-
'SELECT relPath, sizeBytes, modifiedAt FROM directory WHERE accountId = ?',
|
|
110
|
+
'SELECT relPath, sizeBytes, modifiedAt, isDir FROM directory WHERE accountId = ?',
|
|
93
111
|
)
|
|
94
112
|
.bind(session.accountId)
|
|
95
113
|
.all<DirRow>()
|
|
@@ -46,6 +46,9 @@
|
|
|
46
46
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>
|
|
47
47
|
</button>
|
|
48
48
|
<nav id="dp-crumbs" class="dp-crumbs" aria-label="Folder path"></nav>
|
|
49
|
+
<button class="dp-iconbtn" id="dp-refresh" type="button" aria-label="Refresh">
|
|
50
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
|
51
|
+
</button>
|
|
49
52
|
<button class="dp-iconbtn" id="dp-upload-open" type="button" aria-label="Upload a file">
|
|
50
53
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
|
|
51
54
|
</button>
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
var crumbsEl = document.getElementById('dp-crumbs');
|
|
21
21
|
var entriesEl = document.getElementById('dp-entries');
|
|
22
22
|
var upEl = document.getElementById('dp-up');
|
|
23
|
+
var refreshEl = document.getElementById('dp-refresh');
|
|
23
24
|
var uploadOpenEl = document.getElementById('dp-upload-open');
|
|
24
25
|
var modalEl = document.getElementById('dp-modal');
|
|
25
26
|
var modalCloseEl = document.getElementById('dp-modal-close');
|
|
@@ -269,13 +270,22 @@
|
|
|
269
270
|
});
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
/* Resolves true when the list was re-rendered, false when it was not. The
|
|
274
|
+
boolean exists for the refresh control: this function catches its own
|
|
275
|
+
failure, so the promise resolves either way, and a caller that cleared the
|
|
276
|
+
status on resolve would erase the message written a moment earlier. The
|
|
277
|
+
`!d.ok` branch used to return silently, which left a failed re-list
|
|
278
|
+
indistinguishable from an unchanged one. */
|
|
272
279
|
function refresh() {
|
|
273
280
|
return api('/api/files?prefix=' + encodeURIComponent(prefix))
|
|
274
281
|
.then(function (r) {
|
|
275
282
|
return r.json();
|
|
276
283
|
})
|
|
277
284
|
.then(function (d) {
|
|
278
|
-
if (!d.ok)
|
|
285
|
+
if (!d.ok) {
|
|
286
|
+
say('Could not load your files.');
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
279
289
|
render(d.files || []);
|
|
280
290
|
var entries = d.entries || [];
|
|
281
291
|
// Hidden at the root only: once inside a folder the section stays put,
|
|
@@ -283,9 +293,11 @@
|
|
|
283
293
|
// vanishing.
|
|
284
294
|
sharedEl.hidden = prefix === '' && entries.length === 0;
|
|
285
295
|
renderEntries(entries);
|
|
296
|
+
return true;
|
|
286
297
|
})
|
|
287
298
|
.catch(function () {
|
|
288
299
|
say('Could not load your files.');
|
|
300
|
+
return false;
|
|
289
301
|
});
|
|
290
302
|
}
|
|
291
303
|
|
|
@@ -300,8 +312,15 @@
|
|
|
300
312
|
return r.json();
|
|
301
313
|
})
|
|
302
314
|
.then(function (d) {
|
|
303
|
-
|
|
304
|
-
|
|
315
|
+
var done = d.ok ? 'Deleted.' : 'Could not delete that.';
|
|
316
|
+
say(done);
|
|
317
|
+
// refresh() writes its own failure text, which would otherwise bury what
|
|
318
|
+
// just happened to the file. Compose the two, so a list that did not
|
|
319
|
+
// reload is reported without the client losing the delete's outcome and
|
|
320
|
+
// reading a load error as though the delete itself had failed.
|
|
321
|
+
return refresh().then(function (ok) {
|
|
322
|
+
if (!ok) { say(done + ' The list could not be reloaded.'); }
|
|
323
|
+
});
|
|
305
324
|
})
|
|
306
325
|
.catch(function () {
|
|
307
326
|
say('Could not delete that.');
|
|
@@ -370,6 +389,21 @@
|
|
|
370
389
|
refresh();
|
|
371
390
|
});
|
|
372
391
|
|
|
392
|
+
/* Re-read the index in the folder that is already open. refresh() reads the
|
|
393
|
+
current prefix, so this does not reset to the root. The status clears only
|
|
394
|
+
on a reported success, otherwise refresh()'s own error text stays up.
|
|
395
|
+
refresh() returns false from its catch rather than rejecting, so the button
|
|
396
|
+
cannot be left stuck disabled and needs no catch here. `disabled` in flight
|
|
397
|
+
is what stops a double click issuing two reads. */
|
|
398
|
+
refreshEl.addEventListener('click', function () {
|
|
399
|
+
refreshEl.disabled = true;
|
|
400
|
+
say('Refreshing…');
|
|
401
|
+
refresh().then(function (ok) {
|
|
402
|
+
refreshEl.disabled = false;
|
|
403
|
+
if (ok) { say(''); }
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
373
407
|
/* The upload modal. Hidden via the `hidden` attribute so
|
|
374
408
|
[hidden]{display:none!important} stays authoritative over .dp-modal's
|
|
375
409
|
display:flex. It dismisses three ways: the close button, a click on the
|
|
@@ -406,8 +440,15 @@
|
|
|
406
440
|
return r.json();
|
|
407
441
|
})
|
|
408
442
|
.then(function (d) {
|
|
409
|
-
|
|
410
|
-
|
|
443
|
+
var done = d.ok ? 'Uploaded.' : 'Upload failed.';
|
|
444
|
+
say(done);
|
|
445
|
+
// Same composition as remove(): a list that did not reload must not cost
|
|
446
|
+
// the client the upload's own outcome. The resolved shape is unchanged,
|
|
447
|
+
// so the submit and drop handlers still read `responded` and `ok`.
|
|
448
|
+
return refresh().then(function (ok) {
|
|
449
|
+
if (!ok) { say(done + ' The list could not be reloaded.'); }
|
|
450
|
+
return { responded: true, ok: !!d.ok };
|
|
451
|
+
});
|
|
411
452
|
})
|
|
412
453
|
.catch(function () {
|
|
413
454
|
say('Upload failed.');
|
|
@@ -49,10 +49,14 @@ This usually completes within a couple of minutes of the booking.
|
|
|
49
49
|
Bookings are reconciled on a cycle and the system audits itself each run. In the device logs:
|
|
50
50
|
|
|
51
51
|
- `[calendar-booking]` lines trace one booking through submit, write, notify, and done.
|
|
52
|
-
- `[calendar-reconcile]` lines report
|
|
52
|
+
- `[calendar-reconcile]` lines report four health numbers per account: `eligibleExamined` (bookings the check looked at), `pendingUnreconciled` (bookings captured but not yet turned into meetings), `pendingOldestAgeMin` (how many minutes the longest-waiting of those has been waiting) and `orphanMeetings` (booking meetings with no matching saved booking). Entries that only block out a slot are not counted — they are not bookings and never become meetings.
|
|
53
53
|
|
|
54
54
|
A `pendingUnreconciled` number that stays above zero across several runs means those bookings are failing to turn into meetings, and the `attempt=` number on the matching `[calendar-booking]` lines says how many times each has been tried. A booking whose meeting is deleted is picked up again automatically on the next run. To read them: `journalctl --user -u <brand-service> | grep -E '\[calendar-booking\]|\[calendar-reconcile\]'`, then filter by one booking id for its full story. The edge half of a booking (before your device sees it) is in the Cloudflare Pages function logs.
|
|
55
55
|
|
|
56
|
+
A count on its own cannot tell a short lag from a long one: five bookings waiting two minutes and five waiting nine hours are both `pendingUnreconciled=5`. `pendingOldestAgeMin` is what separates them. It reads `0` when nothing is waiting, and also when something is waiting but for under a minute, so read it together with the count beside it.
|
|
57
|
+
|
|
58
|
+
A separate `op=liveness` line is printed every ten minutes by your device's own server, whether or not anything is wrong. `stale=false` means the booking check has run recently. `stale=true` means it has not run in over six minutes, which is the case nothing else can tell you about: a check that has stopped cannot report that it has stopped. `lastSkipReason` names the last account whose check finished without looking at any bookings, and reads `none` when every account it got to was checked. If you run more than one account, a check can both look at one account and skip another, so seeing a recent check time next to a skip reason is normal and means two different accounts.
|
|
59
|
+
|
|
56
60
|
## When your real calendar lives elsewhere
|
|
57
61
|
|
|
58
62
|
The booking page publishes open slots from the internal calendar, the one Maxy keeps in its own graph.
|