@abide/abide 0.41.1 → 0.43.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 +234 -294
- package/CHANGELOG.md +82 -0
- package/README.md +51 -95
- package/package.json +3 -2
- package/src/abideLsp.ts +46 -8
- package/src/abideResolverPlugin.ts +107 -19
- package/src/buildCli.ts +1 -1
- package/src/discoveryEntry.ts +3 -3
- package/src/lib/bundle/BundleMenuItem.ts +1 -1
- package/src/lib/cli/createClient.ts +12 -12
- package/src/lib/cli/dispatchCommand.ts +1 -1
- package/src/lib/cli/printSessionStatus.ts +1 -1
- package/src/lib/cli/resolveCliTarget.ts +1 -1
- package/src/lib/cli/runCli.ts +1 -1
- package/src/lib/cli/types/CliManifest.ts +1 -1
- package/src/lib/cli/types/CliManifestEntry.ts +2 -2
- package/src/lib/mcp/annotationsForMethod.ts +5 -5
- package/src/lib/mcp/createMcpServer.ts +3 -3
- package/src/lib/mcp/mcpSurface.ts +14 -14
- package/src/lib/mcp/types/McpServerOptions.ts +1 -1
- package/src/lib/server/DELETE.ts +4 -4
- package/src/lib/server/GET.ts +4 -4
- package/src/lib/server/HEAD.ts +4 -4
- package/src/lib/server/PATCH.ts +4 -4
- package/src/lib/server/POST.ts +4 -4
- package/src/lib/server/PUT.ts +4 -4
- package/src/lib/server/agent.ts +4 -4
- package/src/lib/server/env.ts +1 -1
- package/src/lib/server/error.ts +49 -7
- package/src/lib/server/json.ts +1 -1
- package/src/lib/server/rpc/buildErrorConstructors.ts +19 -0
- package/src/lib/server/rpc/{defineVerb.ts → defineRpc.ts} +52 -34
- package/src/lib/server/rpc/{dispatchVerbInProcess.ts → dispatchRpcInProcess.ts} +3 -3
- package/src/lib/server/rpc/fieldErrorsFromIssues.ts +23 -0
- package/src/lib/server/rpc/{findVerbByCommandName.ts → findRpcByCommandName.ts} +5 -5
- package/src/lib/server/rpc/parseArgs.ts +6 -6
- package/src/lib/server/rpc/readBodyWithinLimit.ts +2 -2
- package/src/lib/server/rpc/registerRpc.ts +6 -0
- package/src/lib/server/rpc/{verbRegistry.ts → rpcRegistry.ts} +4 -4
- package/src/lib/server/rpc/{runWithVerbTimeout.ts → runWithRpcTimeout.ts} +4 -4
- package/src/lib/server/rpc/types/RemoteHandler.ts +9 -3
- package/src/lib/server/rpc/types/{VerbHelper.ts → RpcHelper.ts} +57 -23
- package/src/lib/server/rpc/types/{VerbRegistryEntry.ts → RpcRegistryEntry.ts} +3 -3
- package/src/lib/server/rpc/types/TypedResponse.ts +2 -2
- package/src/lib/server/rpc/unprocessed.ts +6 -7
- package/src/lib/server/rpc/validationError.ts +17 -0
- package/src/lib/server/runtime/buildInspectorSurface.ts +7 -7
- package/src/lib/server/runtime/buildOpenApiSpec.ts +6 -6
- package/src/lib/server/runtime/createRouteDispatcher.ts +7 -7
- package/src/lib/server/runtime/createServer.ts +4 -4
- package/src/lib/server/runtime/crossOriginForbidden.ts +1 -1
- package/src/lib/server/runtime/crossOriginGate.ts +4 -4
- package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
- package/src/lib/server/runtime/logExposedSurfaces.ts +4 -4
- package/src/lib/server/runtime/registryManifests.ts +2 -2
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
- package/src/lib/server/runtime/types/InspectorRpc.ts +27 -0
- package/src/lib/server/runtime/types/InspectorSurface.ts +4 -4
- package/src/lib/server/runtime/types/RequestStore.ts +1 -1
- package/src/lib/server/runtime/warnUnguardedMcp.ts +2 -2
- package/src/lib/server/sockets/createSocketDispatcher.ts +15 -14
- package/src/lib/server/sockets/types/SocketOperation.ts +2 -2
- package/src/lib/shared/HttpError.ts +15 -1
- package/src/lib/shared/UNREACHABLE_STATUSES.ts +13 -0
- package/src/lib/shared/buildRpcRequest.ts +5 -5
- package/src/lib/shared/cache.ts +50 -168
- package/src/lib/shared/carriesBodyArgs.ts +4 -4
- package/src/lib/shared/createCacheStore.ts +12 -0
- package/src/lib/shared/createRemoteFunction.ts +11 -6
- package/src/lib/shared/decodeResponse.ts +2 -2
- package/src/lib/shared/detectRpcMethod.ts +17 -0
- package/src/lib/shared/emitLogRecord.ts +3 -3
- package/src/lib/shared/escapeRegex.ts +9 -0
- package/src/lib/shared/extraForwardHeaders.ts +1 -1
- package/src/lib/shared/fileName.ts +9 -0
- package/src/lib/shared/fileStem.ts +3 -1
- package/src/lib/shared/findExportCallSite.ts +30 -273
- package/src/lib/shared/forwardHeaders.ts +2 -2
- package/src/lib/shared/httpErrorFor.ts +26 -0
- package/src/lib/shared/isAsciiWhitespace.ts +5 -0
- package/src/lib/shared/isIdentPart.ts +9 -0
- package/src/lib/shared/isIdentStart.ts +8 -0
- package/src/lib/shared/isReadOnlyMethod.ts +4 -4
- package/src/lib/shared/keyForRemoteCall.ts +4 -4
- package/src/lib/shared/log.ts +1 -1
- package/src/lib/shared/outboxProbeSlot.ts +20 -0
- package/src/lib/shared/pending.ts +4 -1
- package/src/lib/shared/prepareRpcModule.ts +96 -14
- package/src/lib/shared/probeRegistries.ts +17 -4
- package/src/lib/shared/programNameForPackage.ts +3 -1
- package/src/lib/shared/queryStringFromArgs.ts +1 -1
- package/src/lib/shared/remoteMetaStore.ts +2 -2
- package/src/lib/shared/resolveClientFlags.ts +1 -1
- package/src/lib/shared/skipNonCode.ts +230 -0
- package/src/lib/shared/streamResponse.ts +9 -6
- package/src/lib/shared/stripImport.ts +6 -4
- package/src/lib/shared/types/CacheEntry.ts +2 -4
- package/src/lib/shared/types/CacheOnContext.ts +1 -11
- package/src/lib/shared/types/CacheStore.ts +13 -7
- package/src/lib/shared/types/ClientFlags.ts +1 -1
- package/src/lib/shared/types/ErrorConstructors.ts +17 -0
- package/src/lib/shared/types/ErrorDescriptor.ts +10 -0
- package/src/lib/shared/types/ErrorSpec.ts +11 -0
- package/src/lib/shared/types/HttpMethod.ts +1 -0
- package/src/lib/shared/types/Outbox.ts +9 -0
- package/src/lib/shared/types/OutboxEntry.ts +27 -0
- package/src/lib/shared/types/RawRemoteFunction.ts +2 -2
- package/src/lib/shared/types/RemoteCallable.ts +2 -2
- package/src/lib/shared/types/RemoteFunction.ts +21 -6
- package/src/lib/shared/types/RequestScopeInfo.ts +1 -1
- package/src/lib/shared/types/RpcErrorGuard.ts +40 -0
- package/src/lib/shared/types/RpcInvoker.ts +1 -1
- package/src/lib/shared/types/StandardSchemaV1.ts +1 -1
- package/src/lib/shared/types/ValidationErrorData.ts +20 -0
- package/src/lib/shared/url.ts +3 -3
- package/src/lib/shared/writeRpcDts.ts +7 -7
- package/src/lib/shared/writeTestRpcDts.ts +3 -3
- package/src/lib/test/createTestApp.ts +10 -10
- package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
- package/src/lib/ui/compile/awaitPlan.ts +48 -0
- package/src/lib/ui/compile/compileShadow.ts +117 -31
- package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
- package/src/lib/ui/compile/desugarSignals.ts +42 -6
- package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
- package/src/lib/ui/compile/generateBuild.ts +45 -62
- package/src/lib/ui/compile/generateSSR.ts +90 -60
- package/src/lib/ui/compile/ifPlan.ts +30 -0
- package/src/lib/ui/compile/makeVarNamer.ts +10 -0
- package/src/lib/ui/compile/offsetToPosition.ts +9 -0
- package/src/lib/ui/compile/parseTemplate.ts +565 -115
- package/src/lib/ui/compile/stripEffects.ts +17 -9
- package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
- package/src/lib/ui/compile/switchPlan.ts +22 -0
- package/src/lib/ui/compile/tryPlan.ts +29 -0
- package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
- package/src/lib/ui/compile/types/TemplateAttr.ts +8 -2
- package/src/lib/ui/compile/types/TemplateNode.ts +15 -2
- package/src/lib/ui/createScope.ts +20 -2
- package/src/lib/ui/dom/appendText.ts +1 -5
- package/src/lib/ui/dom/applyResolved.ts +1 -5
- package/src/lib/ui/dom/disposeRange.ts +6 -10
- package/src/lib/ui/dom/hydrate.ts +2 -5
- package/src/lib/ui/dom/isComment.ts +6 -0
- package/src/lib/ui/dom/mount.ts +1 -2
- package/src/lib/ui/dom/withScope.ts +6 -9
- package/src/lib/ui/effect.ts +4 -2
- package/src/lib/ui/navigate.ts +40 -13
- package/src/lib/ui/outbox.ts +30 -110
- package/src/lib/ui/remoteProxy.ts +160 -10
- package/src/lib/ui/router.ts +3 -3
- package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +281 -0
- package/src/lib/ui/rpcOutbox/outboxRegistry.ts +69 -0
- package/src/lib/ui/runtime/createDoc.ts +3 -3
- package/src/lib/ui/runtime/pathSegments.ts +10 -0
- package/src/lib/ui/socketChannel.ts +7 -1
- package/src/lib/ui/types/Scope.ts +20 -5
- package/template/.zed/settings.json +4 -0
- package/template/CLAUDE.md +1 -1
- package/template/src/server/rpc/getHello.ts +3 -3
- package/template/src/ui/pages/page.abide +4 -4
- package/template/test/app.test.ts +2 -2
- package/src/lib/server/rpc/registerVerb.ts +0 -6
- package/src/lib/server/runtime/types/InspectorVerb.ts +0 -27
- package/src/lib/shared/detectVerbMethod.ts +0 -17
- package/src/lib/shared/types/HttpVerb.ts +0 -1
- package/src/lib/ui/types/Outbox.ts +0 -14
package/src/lib/ui/outbox.ts
CHANGED
|
@@ -1,115 +1,35 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { createDoc as doc } from './runtime/createDoc.ts'
|
|
5
|
-
import type { Outbox } from './types/Outbox.ts'
|
|
6
|
-
import type { PersistenceStore } from './types/PersistenceStore.ts'
|
|
1
|
+
import type { OutboxEntry } from '../shared/types/OutboxEntry.ts'
|
|
2
|
+
import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
|
|
3
|
+
import { outboxRegistry } from './rpcOutbox/outboxRegistry.ts'
|
|
7
4
|
|
|
8
|
-
/* One
|
|
9
|
-
type OutboxEntry<
|
|
5
|
+
/* One entry in the global outbox, tagged with the RPC it belongs to. */
|
|
6
|
+
export type GlobalOutboxEntry = OutboxEntry<unknown> & { rpc: RemoteFunction<unknown, unknown> }
|
|
7
|
+
|
|
8
|
+
/* The global outbox: callable for the flat entry list, awaitable `retry()` to drain every
|
|
9
|
+
queue (resolves when every replay settles). */
|
|
10
|
+
export type GlobalOutbox = (() => GlobalOutboxEntry[]) & { retry: () => Promise<void> }
|
|
11
|
+
|
|
12
|
+
function list(): GlobalOutboxEntry[] {
|
|
13
|
+
return outboxRegistry.all().flatMap(({ rpc, queue }) =>
|
|
14
|
+
queue.entries().map((entry) => ({
|
|
15
|
+
...entry,
|
|
16
|
+
rpc: rpc as RemoteFunction<unknown, unknown>,
|
|
17
|
+
})),
|
|
18
|
+
)
|
|
19
|
+
}
|
|
10
20
|
|
|
11
21
|
/*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
(the effect below re-drains on the online edge);
|
|
20
|
-
- rejected while still online → a real server/validation failure: drop the entry
|
|
21
|
-
so the queue can't wedge, and report it via `onDrop` so the caller can roll back
|
|
22
|
-
the optimistic change it applied (e.g. `history().undo()` or a `cache.patch`).
|
|
23
|
-
Delivery is at-least-once (a crash between a successful send and its dequeue
|
|
24
|
-
re-sends on reload), so `send` should be idempotent. Reconnect RESYNC of reads is
|
|
25
|
-
`cache.on`'s job, not this. Client-intended: `send` performs a network mutation.
|
|
22
|
+
The global, reactive view of every durable RPC's outbox — a flat list of undelivered
|
|
23
|
+
entries across all `outbox` rpcs, each tagged with its `rpc`. Reactive: reading it in a
|
|
24
|
+
template/effect subscribes to each registered queue, so it updates as writes park, drain,
|
|
25
|
+
fail, or cancel. Use it for an app-wide "N unsynced" badge or a sync panel; cancel a
|
|
26
|
+
pending write through `entry.controller.abort()`, retry one through `entry.retry()`, or
|
|
27
|
+
drain everything through `outbox.retry()`. A single rpc's slice is `rpc.outbox`. Server-
|
|
28
|
+
side there are no client queues, so it returns an empty list.
|
|
26
29
|
*/
|
|
27
30
|
// @documentation ui
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
onDrop,
|
|
34
|
-
}: {
|
|
35
|
-
key: string
|
|
36
|
-
send: (payload: T) => Promise<void>
|
|
37
|
-
store?: PersistenceStore
|
|
38
|
-
online?: () => boolean
|
|
39
|
-
onDrop?: (payload: T, error: unknown) => void
|
|
40
|
-
}): Outbox<T> {
|
|
41
|
-
const queue = doc({ items: [] as OutboxEntry<T>[] })
|
|
42
|
-
/* `debounce: 0` because the queue writes itself synchronously below — a queued
|
|
43
|
-
mutation must be durable the instant it's recorded, not after a debounce
|
|
44
|
-
window where a crash could lose it. */
|
|
45
|
-
const persistence = persist(queue, key, { store, debounce: 0 })
|
|
46
|
-
/* Drop the head and make the dequeue durable at once, so a reload never re-sends
|
|
47
|
-
an entry already acknowledged. */
|
|
48
|
-
const dequeue = (): void => {
|
|
49
|
-
queue.remove('items/0')
|
|
50
|
-
persistence.flush()
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/* At most one drain runs at a time; `settled` lets a caller await the active one.
|
|
54
|
-
A drain re-reads the queue each step, so entries enqueued mid-drain are picked
|
|
55
|
-
up by the running loop. An empty drain finishes synchronously (it hits no
|
|
56
|
-
`await`), so `draining` is back to false before the next `enqueue` — there is no
|
|
57
|
-
resolved-but-still-flagged window for a new entry to fall into. */
|
|
58
|
-
let draining = false
|
|
59
|
-
let settled: Promise<void> = Promise.resolve()
|
|
60
|
-
const flush = (): Promise<void> => {
|
|
61
|
-
if (draining) {
|
|
62
|
-
return settled
|
|
63
|
-
}
|
|
64
|
-
draining = true
|
|
65
|
-
settled = (async () => {
|
|
66
|
-
try {
|
|
67
|
-
while (online()) {
|
|
68
|
-
const { items } = queue.snapshot() as { items: OutboxEntry<T>[] }
|
|
69
|
-
const head = items[0]
|
|
70
|
-
if (head === undefined) {
|
|
71
|
-
break
|
|
72
|
-
}
|
|
73
|
-
try {
|
|
74
|
-
await send(head.payload)
|
|
75
|
-
} catch (error) {
|
|
76
|
-
/* Went offline mid-send → keep the head, retry on reconnect. */
|
|
77
|
-
if (!online()) {
|
|
78
|
-
break
|
|
79
|
-
}
|
|
80
|
-
/* Online but rejected → permanent: drop it, report for rollback. */
|
|
81
|
-
dequeue()
|
|
82
|
-
onDrop?.(head.payload, error)
|
|
83
|
-
continue
|
|
84
|
-
}
|
|
85
|
-
dequeue()
|
|
86
|
-
}
|
|
87
|
-
} finally {
|
|
88
|
-
draining = false
|
|
89
|
-
}
|
|
90
|
-
})()
|
|
91
|
-
return settled
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/* Re-drain whenever connectivity returns — reading `online()` subscribes this
|
|
95
|
-
effect, so the offline→online edge fires it. */
|
|
96
|
-
const stop = effect(() => {
|
|
97
|
-
if (online()) {
|
|
98
|
-
void flush()
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
enqueue: (payload: T) => {
|
|
104
|
-
queue.add('items/-', { id: crypto.randomUUID(), payload })
|
|
105
|
-
persistence.flush() // durable before we attempt the network
|
|
106
|
-
void flush()
|
|
107
|
-
},
|
|
108
|
-
pending: () => (queue.read('items') as OutboxEntry<T>[]).map((entry) => entry.payload),
|
|
109
|
-
flush,
|
|
110
|
-
dispose: () => {
|
|
111
|
-
stop()
|
|
112
|
-
persistence.dispose()
|
|
113
|
-
},
|
|
114
|
-
}
|
|
115
|
-
}
|
|
31
|
+
export const outbox: GlobalOutbox = Object.assign(list, {
|
|
32
|
+
async retry(): Promise<void> {
|
|
33
|
+
await Promise.all(outboxRegistry.all().map(({ queue }) => queue.retry()))
|
|
34
|
+
},
|
|
35
|
+
})
|
|
@@ -6,17 +6,34 @@ import { HttpError } from '../shared/HttpError.ts'
|
|
|
6
6
|
import { OFFLINE_HEADER } from '../shared/OFFLINE_HEADER.ts'
|
|
7
7
|
import { rpcTimeoutSlot } from '../shared/rpcTimeoutSlot.ts'
|
|
8
8
|
import { trace } from '../shared/trace.ts'
|
|
9
|
-
import type {
|
|
9
|
+
import type { HttpMethod } from '../shared/types/HttpMethod.ts'
|
|
10
|
+
import type { Outbox } from '../shared/types/Outbox.ts'
|
|
10
11
|
import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
|
|
11
12
|
import type { RpcOptions } from '../shared/types/RpcOptions.ts'
|
|
13
|
+
import { UNREACHABLE_STATUSES } from '../shared/UNREACHABLE_STATUSES.ts'
|
|
12
14
|
import { withBase } from '../shared/withBase.ts'
|
|
15
|
+
import { createOutboxQueue, type OutboxQueue } from './rpcOutbox/createOutboxQueue.ts'
|
|
16
|
+
import { outboxRegistry } from './rpcOutbox/outboxRegistry.ts'
|
|
13
17
|
import { currentAbortSignal } from './runtime/currentAbortSignal.ts'
|
|
14
18
|
import { REQUEST_SUPERSEDED } from './runtime/REQUEST_SUPERSEDED.ts'
|
|
19
|
+
import type { PersistenceStore } from './types/PersistenceStore.ts'
|
|
20
|
+
|
|
21
|
+
/* The framework-reserved `HttpError.kind` for a request the durable outbox parked because
|
|
22
|
+
the server was unreachable — distinct from a handler-declared error name. Lets a caller
|
|
23
|
+
branch with `error instanceof HttpError && error.kind === 'queued'`. */
|
|
24
|
+
const QUEUED = 'queued'
|
|
25
|
+
|
|
26
|
+
/* A durable RPC's per-call options. `outbox: true` parks an unreachable call for replay;
|
|
27
|
+
`store` exists for testing — production uses the default persistence store. */
|
|
28
|
+
export type DurableOptions = {
|
|
29
|
+
outbox?: boolean
|
|
30
|
+
store?: PersistenceStore
|
|
31
|
+
}
|
|
15
32
|
|
|
16
33
|
/*
|
|
17
|
-
Client-side substitute for a
|
|
18
|
-
call per
|
|
19
|
-
target uses
|
|
34
|
+
Client-side substitute for a rpc-defined handler. The bundler emits one
|
|
35
|
+
call per rpc export inside an `$rpc/**` module (GET / POST / …): server
|
|
36
|
+
target uses defineRpc (real handler), browser target uses remoteProxy
|
|
20
37
|
(fetch over the network). Both paths produce identical RemoteFunction
|
|
21
38
|
shapes and identical WeakMap metadata so cache() works the same on either
|
|
22
39
|
side.
|
|
@@ -25,13 +42,26 @@ side.
|
|
|
25
42
|
the query string (GET/DELETE/HEAD). Plain `fn(args)` decodes the Response
|
|
26
43
|
by Content-Type and throws HttpError on non-2xx; `.raw(args)` is the
|
|
27
44
|
escape hatch that returns the Response untouched.
|
|
45
|
+
|
|
46
|
+
A durable (`outbox: true`) rpc is still a normal RemoteFunction — it fetches and
|
|
47
|
+
throws exactly the same. The differences: when the server can't be reached
|
|
48
|
+
(a transport failure, or a 502/503/504/52x), the request is `park`ed onto the
|
|
49
|
+
RPC's app-owned outbox as a SIDE-EFFECT and the throw is a `kind: 'queued'`
|
|
50
|
+
HttpError; and once a backlog exists, a fresh call parks straight to the TAIL
|
|
51
|
+
(no fetch) so writes can't land out of order. The parked write waits for
|
|
52
|
+
`rpc.outbox.retry()` (or the global `outbox.retry()`) — there is no auto-drain;
|
|
53
|
+
the app owns when to replay. `rpc.outbox()` exposes the queue.
|
|
28
54
|
*/
|
|
29
55
|
// @documentation plumbing
|
|
30
56
|
export function remoteProxy<Args, Return>(
|
|
31
|
-
method:
|
|
57
|
+
method: HttpMethod,
|
|
32
58
|
url: string,
|
|
59
|
+
durable?: DurableOptions,
|
|
33
60
|
): RemoteFunction<Args, Return> {
|
|
34
|
-
|
|
61
|
+
/* Assigned after `createRemoteFunction` so the invoke closure (which runs later, per
|
|
62
|
+
call) parks through the shared queue; undefined leaves the plain fetch path. */
|
|
63
|
+
let queue: OutboxQueue<Args> | undefined
|
|
64
|
+
const fn = createRemoteFunction<Args, Return>({
|
|
35
65
|
method,
|
|
36
66
|
url,
|
|
37
67
|
clients: browserClientFlags,
|
|
@@ -49,12 +79,132 @@ export function remoteProxy<Args, Return>(
|
|
|
49
79
|
headers: rpcHeaders(opts?.headers),
|
|
50
80
|
}),
|
|
51
81
|
/*
|
|
52
|
-
Forcing `getRequest()` once builds the Request and seeds the
|
|
53
|
-
|
|
54
|
-
|
|
82
|
+
Forcing `getRequest()` once builds the Request and seeds the cache meta thunk in
|
|
83
|
+
createRemoteFunction with the same instance, so cache() readers don't reconstruct
|
|
84
|
+
it. On a durable rpc, an unreachable result parks a pristine CLONE and throws a
|
|
85
|
+
`queued`-tagged HttpError — `fetch` consumes the original (its body stream is read
|
|
86
|
+
and locked), so parking that same instance would leave the queue a request a resend
|
|
87
|
+
can't reconstruct and a capture can't read. The clone is parked, the original is
|
|
88
|
+
sent. The throw lets the caller branch on `error.kind === 'queued'` (parked, will
|
|
89
|
+
retry) vs. a real server rejection; `error.data` is the parked entry, so a caller
|
|
90
|
+
can `await (error.data as OutboxEntry).settled` for the eventual outcome.
|
|
55
91
|
*/
|
|
56
|
-
invoke: (
|
|
92
|
+
invoke: (args, getRequest, opts) => {
|
|
93
|
+
if (queue === undefined) {
|
|
94
|
+
return fetchWithTimeout(getRequest(), opts)
|
|
95
|
+
}
|
|
96
|
+
/* A non-empty queue means an undelivered backlog: park this call at the TAIL
|
|
97
|
+
and throw, rather than let a live fetch leapfrog the older writes and land
|
|
98
|
+
out of order. `retry()` then flushes the whole queue FIFO. */
|
|
99
|
+
if (queue.size() > 0) {
|
|
100
|
+
return Promise.reject(
|
|
101
|
+
queuedThrow(
|
|
102
|
+
queue,
|
|
103
|
+
args as Args,
|
|
104
|
+
getRequest().clone(),
|
|
105
|
+
unreachableResponse(),
|
|
106
|
+
undefined,
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
const request = getRequest()
|
|
111
|
+
const parkable = request.clone()
|
|
112
|
+
return fetchWithTimeout(request, opts).then(
|
|
113
|
+
(response) => {
|
|
114
|
+
if (UNREACHABLE_STATUSES.has(response.status)) {
|
|
115
|
+
throw queuedThrow(
|
|
116
|
+
queue,
|
|
117
|
+
args as Args,
|
|
118
|
+
parkable,
|
|
119
|
+
response,
|
|
120
|
+
new HttpError(response.clone()),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
return response
|
|
124
|
+
},
|
|
125
|
+
(error: unknown) => {
|
|
126
|
+
if (shouldParkRejection(error)) {
|
|
127
|
+
const response =
|
|
128
|
+
error instanceof HttpError ? error.response : unreachableResponse()
|
|
129
|
+
throw queuedThrow(queue, args as Args, parkable, response, error)
|
|
130
|
+
}
|
|
131
|
+
throw error
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
},
|
|
57
135
|
})
|
|
136
|
+
if (durable?.outbox === true) {
|
|
137
|
+
queue = getOrCreateOutboxQueue<Args, Return>(url, fn, durable)
|
|
138
|
+
Object.assign(fn, { outbox: outboxFace(queue) })
|
|
139
|
+
}
|
|
140
|
+
return fn
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* The synthetic "unreachable" Response a park reuses when there is no real one — a
|
|
144
|
+
transport failure (fetch rejected) or a backlog park that never fetched. */
|
|
145
|
+
function unreachableResponse(): Response {
|
|
146
|
+
return new Response('queued', { status: 503, statusText: 'Service Unavailable' })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Park the unreachable request (`cause` becomes the entry's parked reason, `entry.error`)
|
|
150
|
+
and return the `kind: 'queued'` HttpError to throw — its `.data` is the parked entry, so
|
|
151
|
+
a caller can `await (error.data as OutboxEntry).settled` for the eventual delivered
|
|
152
|
+
result or server refusal. */
|
|
153
|
+
function queuedThrow<Args>(
|
|
154
|
+
queue: OutboxQueue<Args> | undefined,
|
|
155
|
+
args: Args,
|
|
156
|
+
request: Request,
|
|
157
|
+
response: Response,
|
|
158
|
+
cause: unknown,
|
|
159
|
+
): HttpError {
|
|
160
|
+
const entry = queue?.park(args, request, cause)
|
|
161
|
+
return new HttpError(response, QUEUED, entry)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* A fetch REJECTION (no Response) the durable rpc should park: a transport failure or
|
|
165
|
+
the synthesized client-timeout 504. NOT a caller/scope abort — that's a deliberate
|
|
166
|
+
cancel, not the server being unreachable. (HTTP error STATUSES never reject — `fetch`
|
|
167
|
+
resolves with them — so 4xx/500 are classified on the response, not here.) */
|
|
168
|
+
function shouldParkRejection(error: unknown): boolean {
|
|
169
|
+
if (error === REQUEST_SUPERSEDED) {
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
if (error instanceof HttpError) {
|
|
176
|
+
return UNREACHABLE_STATUSES.has(error.response.status)
|
|
177
|
+
}
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* The single app-owned queue for a durable RPC url — created + registered on first use
|
|
182
|
+
so every call site (and the global `outbox()`) shares one queue. The send is a plain
|
|
183
|
+
`fetch`; createOutboxQueue rides the entry's abort signal on the resent Request and
|
|
184
|
+
keeps scope-abort + the client timeout out. */
|
|
185
|
+
function getOrCreateOutboxQueue<Args, Return>(
|
|
186
|
+
url: string,
|
|
187
|
+
rpc: RemoteFunction<Args, Return>,
|
|
188
|
+
durable: DurableOptions,
|
|
189
|
+
): OutboxQueue<Args> {
|
|
190
|
+
const existing = outboxRegistry.get(url)
|
|
191
|
+
if (existing !== undefined) {
|
|
192
|
+
return existing as OutboxQueue<Args>
|
|
193
|
+
}
|
|
194
|
+
const queue = createOutboxQueue<Args>({
|
|
195
|
+
url,
|
|
196
|
+
send: (request) => fetch(request),
|
|
197
|
+
store: durable.store,
|
|
198
|
+
})
|
|
199
|
+
outboxRegistry.register(url, queue as OutboxQueue<unknown>, rpc)
|
|
200
|
+
return queue
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* The `.outbox` face: callable for the live entries, `retry()` to drain on demand. */
|
|
204
|
+
function outboxFace<Args>(queue: OutboxQueue<Args>): Outbox<Args> {
|
|
205
|
+
const face = (() => queue.entries()) as Outbox<Args>
|
|
206
|
+
face.retry = () => queue.retry()
|
|
207
|
+
return face
|
|
58
208
|
}
|
|
59
209
|
|
|
60
210
|
/*
|
package/src/lib/ui/router.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { fillBoundary } from './dom/fillBoundary.ts'
|
|
|
3
3
|
import { outlet } from './dom/outlet.ts'
|
|
4
4
|
import { effect } from './effect.ts'
|
|
5
5
|
import { matchRoute } from './matchRoute.ts'
|
|
6
|
-
import {
|
|
6
|
+
import { navigatePath } from './navigate.ts'
|
|
7
7
|
import { clientPage } from './runtime/clientPage.ts'
|
|
8
8
|
import { enterRenderPass } from './runtime/enterRenderPass.ts'
|
|
9
9
|
import { exitRenderPass } from './runtime/exitRenderPass.ts'
|
|
@@ -294,7 +294,7 @@ export function router(
|
|
|
294
294
|
event.preventDefault()
|
|
295
295
|
/* Carry the query and hash, not just the pathname, so the destination page's
|
|
296
296
|
page.url sees them and an SPA navigation matches a fresh server load. */
|
|
297
|
-
|
|
297
|
+
navigatePath(destination.pathname + destination.search + destination.hash)
|
|
298
298
|
}
|
|
299
299
|
if (typeof window !== 'undefined') {
|
|
300
300
|
/* Own scroll restoration: the browser would restore against the pre-teardown
|
|
@@ -399,7 +399,7 @@ export function router(
|
|
|
399
399
|
/* handle() redirected: go where it pointed, replacing the blocked
|
|
400
400
|
URL so back doesn't trap on it. The router re-probes the target. */
|
|
401
401
|
if (decision.kind === 'redirect') {
|
|
402
|
-
|
|
402
|
+
navigatePath(decision.path, { replace: true })
|
|
403
403
|
return
|
|
404
404
|
}
|
|
405
405
|
/* handle() blocked it / redirected off-origin / the probe failed:
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { decodeResponse } from '../../shared/decodeResponse.ts'
|
|
2
|
+
import type { OutboxEntry, OutboxStatus } from '../../shared/types/OutboxEntry.ts'
|
|
3
|
+
import { UNREACHABLE_STATUSES } from '../../shared/UNREACHABLE_STATUSES.ts'
|
|
4
|
+
import { persist } from '../persist.ts'
|
|
5
|
+
import { createDoc as doc } from '../runtime/createDoc.ts'
|
|
6
|
+
import type { PersistenceStore } from '../types/PersistenceStore.ts'
|
|
7
|
+
|
|
8
|
+
/* Persisted form of an entry — the Request reduced to its replayable parts (a live
|
|
9
|
+
Request + AbortController don't serialize). `body`/`contentType` are captured
|
|
10
|
+
asynchronously just after park (Request.text() is async); they seed empty and fill
|
|
11
|
+
in before any reload, so a restored entry replays with its original body. */
|
|
12
|
+
type StoredEntry<Args> = {
|
|
13
|
+
id: string
|
|
14
|
+
args: Args
|
|
15
|
+
method: string
|
|
16
|
+
url: string
|
|
17
|
+
body: string
|
|
18
|
+
contentType: string
|
|
19
|
+
status: OutboxStatus
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Non-serializable identity for a live entry: its abort handle, the in-session Request
|
|
23
|
+
(body intact), and the per-entry retry trigger. Rebuilt from the persisted item on a
|
|
24
|
+
cold read after reload. */
|
|
25
|
+
type Identity = { controller: AbortController; request: Request; retry: () => Promise<void> }
|
|
26
|
+
|
|
27
|
+
export type OutboxQueue<Args> = {
|
|
28
|
+
park: (args: Args, request: Request, reason?: unknown) => OutboxEntry<Args>
|
|
29
|
+
entries: () => OutboxEntry<Args>[]
|
|
30
|
+
/* Undelivered-entry count — the same reactive read as `entries()`, without building
|
|
31
|
+
the entry objects. A caller parks straight to the tail when this is non-empty. */
|
|
32
|
+
size: () => number
|
|
33
|
+
retry: () => Promise<void>
|
|
34
|
+
drain: () => Promise<void>
|
|
35
|
+
dispose: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Persistence key per RPC url — the queue's durable identity. */
|
|
39
|
+
const keyFor = (url: string): string => `abide:outbox:${url}`
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
A durable, app-owned FIFO retry queue for one RPC. A call fetches directly and throws as
|
|
43
|
+
before while the queue is empty; when the server can't be reached — a transport failure or
|
|
44
|
+
a 502/503/504/52x — the caller `park`s the request here (a side-effect; the call still
|
|
45
|
+
throws), and once a backlog exists the caller parks every later call at the tail too, so
|
|
46
|
+
FIFO order is preserved on replay. Built on `doc` + `persist`, so the queue IS a persisted
|
|
47
|
+
document that survives a reload. Draining is manual — the app calls `retry()` (per-entry or
|
|
48
|
+
whole-queue) when it decides to replay; nothing drains automatically. The drain re-sends
|
|
49
|
+
head-first: any server response removes the entry — a 2xx (delivered) AND a real 4xx/500
|
|
50
|
+
(the server handled+rejected it; retrying won't help, so it leaves the queue) — while a
|
|
51
|
+
still-unreachable result keeps it `queued` for the next `retry()`, and an abort removes it.
|
|
52
|
+
Resends run under the entry's OWN abort signal alone — scope-abort and the client timeout
|
|
53
|
+
are deliberately not composed in, so a parked write survives an unmount and waits out the
|
|
54
|
+
outage.
|
|
55
|
+
*/
|
|
56
|
+
export function createOutboxQueue<Args>(opts: {
|
|
57
|
+
url: string
|
|
58
|
+
send: (request: Request) => Promise<Response>
|
|
59
|
+
store?: PersistenceStore
|
|
60
|
+
}): OutboxQueue<Args> {
|
|
61
|
+
const stored = doc({ items: [] as StoredEntry<Args>[] })
|
|
62
|
+
const persistence = persist(stored, keyFor(opts.url), { store: opts.store, debounce: 0 })
|
|
63
|
+
/* Live identity (controller + Request + retry) by id, rebuilt from persisted items on
|
|
64
|
+
a cold read; `errors` is transient last-failure state (never persisted). */
|
|
65
|
+
const live = new Map<string, Identity>()
|
|
66
|
+
const errors = new Map<string, unknown>()
|
|
67
|
+
/* Per-entry `settled` deferreds, created lazily on first read of `entry.settled` (so a
|
|
68
|
+
never-awaited refusal can't become an unhandled rejection) and resolved/rejected once
|
|
69
|
+
at the drain exit. Transient — a reload starts fresh. */
|
|
70
|
+
type Settler = {
|
|
71
|
+
promise: Promise<unknown>
|
|
72
|
+
resolve: (value: unknown) => void
|
|
73
|
+
reject: (error: unknown) => void
|
|
74
|
+
}
|
|
75
|
+
const settlers = new Map<string, Settler>()
|
|
76
|
+
|
|
77
|
+
/* The lazily-armed `settled` promise for an id: the outcome of this write as if the
|
|
78
|
+
original call had reached the server. */
|
|
79
|
+
const settledFor = (id: string): Promise<unknown> => {
|
|
80
|
+
const existing = settlers.get(id)
|
|
81
|
+
if (existing !== undefined) {
|
|
82
|
+
return existing.promise
|
|
83
|
+
}
|
|
84
|
+
let resolve!: (value: unknown) => void
|
|
85
|
+
let reject!: (error: unknown) => void
|
|
86
|
+
const promise = new Promise<unknown>((res, rej) => {
|
|
87
|
+
resolve = res
|
|
88
|
+
reject = rej
|
|
89
|
+
})
|
|
90
|
+
settlers.set(id, { promise, resolve, reject })
|
|
91
|
+
return promise
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Settle an id's deferred (if armed) and drop it — single-shot. */
|
|
95
|
+
const settle = (id: string, run: (settler: Settler) => void): void => {
|
|
96
|
+
const settler = settlers.get(id)
|
|
97
|
+
if (settler !== undefined) {
|
|
98
|
+
run(settler)
|
|
99
|
+
settlers.delete(id)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const items = (): StoredEntry<Args>[] => stored.read('items') as StoredEntry<Args>[]
|
|
104
|
+
|
|
105
|
+
const remove = (id: string): void => {
|
|
106
|
+
const index = items().findIndex((item) => item.id === id)
|
|
107
|
+
if (index !== -1) {
|
|
108
|
+
stored.remove(`items/${index}`)
|
|
109
|
+
persistence.flush()
|
|
110
|
+
}
|
|
111
|
+
live.delete(id)
|
|
112
|
+
errors.delete(id)
|
|
113
|
+
/* A cancel/supersede removal with nobody having delivered first → reject `settled`
|
|
114
|
+
as a canceled call would. Delivery/refusal settle before calling remove, so this
|
|
115
|
+
is a no-op for them (single-shot). */
|
|
116
|
+
settle(id, (s) => s.reject(new DOMException('The outbox entry was canceled', 'AbortError')))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const setStatus = (id: string, status: OutboxStatus): void => {
|
|
120
|
+
const index = items().findIndex((item) => item.id === id)
|
|
121
|
+
if (index !== -1) {
|
|
122
|
+
stored.replace(`items/${index}/status`, status)
|
|
123
|
+
persistence.flush()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* The non-serializable identity for an id: reuse the live one (Request body intact in
|
|
128
|
+
session), else rebuild from the persisted parts (fresh controller, cold reload). */
|
|
129
|
+
const identityFor = (item: StoredEntry<Args>): Identity => {
|
|
130
|
+
const existing = live.get(item.id)
|
|
131
|
+
if (existing !== undefined) {
|
|
132
|
+
return existing
|
|
133
|
+
}
|
|
134
|
+
const controller = new AbortController()
|
|
135
|
+
controller.signal.addEventListener('abort', () => remove(item.id), { once: true })
|
|
136
|
+
/* `body || undefined` so a bodyless method rebuilds without one (a Request with a
|
|
137
|
+
body-carrying method + no body is fine; GET/DELETE with a body throws). */
|
|
138
|
+
const request = new Request(item.url, {
|
|
139
|
+
method: item.method,
|
|
140
|
+
body: item.body || undefined,
|
|
141
|
+
headers: item.contentType ? { 'content-type': item.contentType } : undefined,
|
|
142
|
+
})
|
|
143
|
+
/* Per-entry `retry()` kicks a FIFO drain — every entry is `queued`, so it resends
|
|
144
|
+
head-first and reaches this one in order. */
|
|
145
|
+
const identity: Identity = { controller, request, retry: () => drain() }
|
|
146
|
+
live.set(item.id, identity)
|
|
147
|
+
return identity
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const toLive = (item: StoredEntry<Args>): OutboxEntry<Args> => {
|
|
151
|
+
const { controller, request, retry } = identityFor(item)
|
|
152
|
+
return {
|
|
153
|
+
id: item.id,
|
|
154
|
+
controller,
|
|
155
|
+
request,
|
|
156
|
+
args: item.args,
|
|
157
|
+
status: item.status,
|
|
158
|
+
error: errors.get(item.id),
|
|
159
|
+
retry,
|
|
160
|
+
/* Lazy: the deferred is only armed when someone reads `settled`, so an
|
|
161
|
+
unawaited refusal can't surface as an unhandled rejection. */
|
|
162
|
+
get settled() {
|
|
163
|
+
return settledFor(item.id)
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* Persist the request body + content type just after park (Request.text() is async).
|
|
169
|
+
The in-session drain replays the LIVE request (body intact) regardless; this fills
|
|
170
|
+
the persisted form before any reload. A bodyless method leaves the empty seed. */
|
|
171
|
+
const captureRequest = async (id: string, request: Request): Promise<void> => {
|
|
172
|
+
const body = request.body === null ? '' : await request.clone().text()
|
|
173
|
+
const contentType = request.headers.get('content-type') ?? ''
|
|
174
|
+
const index = items().findIndex((item) => item.id === id)
|
|
175
|
+
if (index === -1) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
stored.replace(`items/${index}/body`, body)
|
|
179
|
+
stored.replace(`items/${index}/contentType`, contentType)
|
|
180
|
+
persistence.flush()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Resends run under the entry's OWN signal only — a parked write survives unmount +
|
|
184
|
+
waits out the outage, so scope-abort + client-timeout are NOT composed in. */
|
|
185
|
+
const sendable = (entry: OutboxEntry<Args>): Request =>
|
|
186
|
+
new Request(entry.request, { signal: entry.controller.signal })
|
|
187
|
+
|
|
188
|
+
let draining = false
|
|
189
|
+
let settled: Promise<void> = Promise.resolve()
|
|
190
|
+
const drain = (): Promise<void> => {
|
|
191
|
+
if (draining) {
|
|
192
|
+
return settled
|
|
193
|
+
}
|
|
194
|
+
draining = true
|
|
195
|
+
settled = (async () => {
|
|
196
|
+
try {
|
|
197
|
+
while (true) {
|
|
198
|
+
const head = items()[0]
|
|
199
|
+
if (head === undefined) {
|
|
200
|
+
break
|
|
201
|
+
}
|
|
202
|
+
const entry = toLive(head)
|
|
203
|
+
if (entry.controller.signal.aborted) {
|
|
204
|
+
remove(head.id) // canceled while queued
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
setStatus(head.id, 'sending')
|
|
208
|
+
try {
|
|
209
|
+
const response = await opts.send(sendable(entry))
|
|
210
|
+
if (UNREACHABLE_STATUSES.has(response.status)) {
|
|
211
|
+
setStatus(head.id, 'queued') // still down → wait for the next trigger
|
|
212
|
+
break
|
|
213
|
+
}
|
|
214
|
+
/* The server responded — 2xx OR a real 4xx/500. Either way it
|
|
215
|
+
HANDLED the request (the outbox only holds the unreachable), so
|
|
216
|
+
the entry leaves the queue and the next proceeds in order. Decode
|
|
217
|
+
it exactly as a live call would so `settled` resolves with the
|
|
218
|
+
result, or rejects with the real HttpError on a refusal. */
|
|
219
|
+
try {
|
|
220
|
+
const value = await decodeResponse(response)
|
|
221
|
+
settle(head.id, (s) => s.resolve(value))
|
|
222
|
+
} catch (responseError) {
|
|
223
|
+
settle(head.id, (s) => s.reject(responseError))
|
|
224
|
+
}
|
|
225
|
+
remove(head.id)
|
|
226
|
+
continue
|
|
227
|
+
} catch {
|
|
228
|
+
if (entry.controller.signal.aborted) {
|
|
229
|
+
remove(head.id) // canceled mid-send
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
/* Transport failure on resend — still unreachable, keep queued. */
|
|
233
|
+
setStatus(head.id, 'queued')
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} finally {
|
|
238
|
+
draining = false
|
|
239
|
+
}
|
|
240
|
+
})()
|
|
241
|
+
return settled
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
park(args, request, reason) {
|
|
246
|
+
const id = crypto.randomUUID()
|
|
247
|
+
const controller = new AbortController()
|
|
248
|
+
controller.signal.addEventListener('abort', () => remove(id), { once: true })
|
|
249
|
+
live.set(id, { controller, request, retry: () => drain() })
|
|
250
|
+
if (reason !== undefined) {
|
|
251
|
+
errors.set(id, reason)
|
|
252
|
+
}
|
|
253
|
+
stored.add('items/-', {
|
|
254
|
+
id,
|
|
255
|
+
args,
|
|
256
|
+
method: request.method,
|
|
257
|
+
url: request.url,
|
|
258
|
+
body: '',
|
|
259
|
+
contentType: '',
|
|
260
|
+
status: 'queued',
|
|
261
|
+
})
|
|
262
|
+
persistence.flush()
|
|
263
|
+
void captureRequest(id, request)
|
|
264
|
+
const item = items().find((entry) => entry.id === id) as StoredEntry<Args>
|
|
265
|
+
return toLive(item)
|
|
266
|
+
},
|
|
267
|
+
entries() {
|
|
268
|
+
return items().map(toLive)
|
|
269
|
+
},
|
|
270
|
+
size() {
|
|
271
|
+
return items().length
|
|
272
|
+
},
|
|
273
|
+
retry() {
|
|
274
|
+
return drain()
|
|
275
|
+
},
|
|
276
|
+
drain,
|
|
277
|
+
dispose() {
|
|
278
|
+
persistence.dispose()
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
}
|