@lunora/db 0.0.0 → 1.0.0-alpha.10
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/LICENSE.md +105 -0
- package/README.md +134 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/collections/index.d.mts +5 -0
- package/dist/collections/index.d.ts +5 -0
- package/dist/collections/index.mjs +2 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.mjs +8 -0
- package/dist/mutators/index.d.mts +5 -0
- package/dist/mutators/index.d.ts +5 -0
- package/dist/mutators/index.mjs +1 -0
- package/dist/packem_shared/OUTBOX_MUTATION_FN_NAME-Cf8iP6Wa.mjs +102 -0
- package/dist/packem_shared/bindMutators-DNhICkoy.mjs +66 -0
- package/dist/packem_shared/collection-options.d-lJBOVJgq.d.mts +216 -0
- package/dist/packem_shared/collection-options.d-lJBOVJgq.d.ts +216 -0
- package/dist/packem_shared/createCheckpointRegistry-SvwBczzv.mjs +127 -0
- package/dist/packem_shared/define-collections.d-C01DaKJO.d.ts +159 -0
- package/dist/packem_shared/define-collections.d-Cc1p0x3t.d.mts +159 -0
- package/dist/packem_shared/define-mutators.d-DhO8mWz0.d.ts +80 -0
- package/dist/packem_shared/define-mutators.d-HWj94_nL.d.mts +80 -0
- package/dist/packem_shared/defineCollections-BWB7CJzY.mjs +105 -0
- package/package.json +52 -17
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { LunoraClient } from '@lunora/client';
|
|
2
|
+
import { Collection, Transaction } from '@tanstack/db';
|
|
3
|
+
import { C as CheckpointRegistry, R as Row } from "./collection-options.d-lJBOVJgq.js";
|
|
4
|
+
/** The local store a client mutator's optimistic body writes against. */
|
|
5
|
+
interface ClientMutatorContext {
|
|
6
|
+
/** The wired collections, keyed by name — apply optimistic inserts/updates/deletes here. */
|
|
7
|
+
collections: Record<string, Collection<Row, string>>;
|
|
8
|
+
}
|
|
9
|
+
/** A client-side custom mutator: an optimistic body plus the path of its authoritative server impl. */
|
|
10
|
+
interface ClientMutatorDef<TArgs> {
|
|
11
|
+
/** Brand so codegen / `bindMutators` can recognize a mutator definition. */
|
|
12
|
+
__lunoraClientMutator: true;
|
|
13
|
+
/** The optimistic update applied to the local collections before the server confirms. */
|
|
14
|
+
apply: (context: ClientMutatorContext, args: TArgs) => void;
|
|
15
|
+
/** The Lunora function path of the server-authoritative mutator (`defineMutator` on the server). */
|
|
16
|
+
serverRef: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Declare a client-side custom mutator. `apply` runs optimistically against the
|
|
20
|
+
* local TanStack collections; `serverRef` names the authoritative server mutator
|
|
21
|
+
* the write is pushed to over the watermark protocol. The server impl is the
|
|
22
|
+
* linearization point — this body is a prediction the server can override.
|
|
23
|
+
*/
|
|
24
|
+
declare const defineMutator: <TArgs = Record<string, unknown>>(definition: {
|
|
25
|
+
apply: (context: ClientMutatorContext, args: TArgs) => void;
|
|
26
|
+
serverRef: string;
|
|
27
|
+
}) => ClientMutatorDef<TArgs>;
|
|
28
|
+
type AnyMutatorMap = Record<string, ClientMutatorDef<any>>;
|
|
29
|
+
/** Args type of a mutator definition. */
|
|
30
|
+
type ArgsOf<M> = M extends ClientMutatorDef<infer A> ? A : never;
|
|
31
|
+
/** Inputs `bindMutators` needs to run a mutator: the local store + how the overlay drops. */
|
|
32
|
+
interface BindMutatorsContext {
|
|
33
|
+
/**
|
|
34
|
+
* Resolves the optimistic-overlay drop against confirmed server watermarks.
|
|
35
|
+
* When supplied, a mutation's overlay is held until the sync stream echoes
|
|
36
|
+
* `lastMutationId >= clientSeq` (via {@link CheckpointRegistry.resolve}) — no
|
|
37
|
+
* flicker. When omitted, the overlay drops as soon as the server accepts the
|
|
38
|
+
* write (the by-value sync diff then converges the synced row in place).
|
|
39
|
+
*/
|
|
40
|
+
checkpoints?: CheckpointRegistry;
|
|
41
|
+
/** The wired collections the optimistic bodies write against. */
|
|
42
|
+
collections: Record<string, Collection<Row, string>>;
|
|
43
|
+
/** Optional shard key the mutator's server push is routed to. */
|
|
44
|
+
shardKey?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Calling a bound mutator runs the optimistic body + pushes the server write; returns the TanStack transaction. */
|
|
47
|
+
type BoundMutators<M extends AnyMutatorMap> = { [K in keyof M]: (args: ArgsOf<M[K]>) => Transaction };
|
|
48
|
+
/**
|
|
49
|
+
* Bind a set of client mutators to a client + local store. Each returned handle,
|
|
50
|
+
* when called, opens a TanStack optimistic transaction: the mutator's `apply`
|
|
51
|
+
* body writes the predicted rows into the collections, and the transaction's
|
|
52
|
+
* `mutationFn` pushes the authoritative write through
|
|
53
|
+
* {@link LunoraClient.callMutator} under a monotonic per-client `clientSeq`.
|
|
54
|
+
*
|
|
55
|
+
* Rebase-on-poke is free — TanStack DB re-derives every pending optimistic overlay
|
|
56
|
+
* over the latest synced base on each sync tick. The overlay is dropped when the
|
|
57
|
+
* server confirms the write (and, if `checkpoints` is supplied, once it echoes the
|
|
58
|
+
* matching watermark so the synced row has landed).
|
|
59
|
+
*
|
|
60
|
+
* The `clientSeq` generator is seeded from the server's echoed watermark
|
|
61
|
+
* ({@link LunoraClient.confirmedMutationWatermark}) on every issue, so a reload —
|
|
62
|
+
* which resets this in-memory counter while the server keeps a durable per-client
|
|
63
|
+
* watermark — never reissues a sequence the DO has already applied. As a backstop
|
|
64
|
+
* for the very first push of a fresh session (before any ack has taught the client
|
|
65
|
+
* the watermark), a push the DO swallows as a replay (`applied === false`) is
|
|
66
|
+
* reissued above the now-known watermark instead of being mistaken for a confirmed
|
|
67
|
+
* write — closing the silent-drop window without risking a double-apply (a fresh
|
|
68
|
+
* session's first stale push provably cannot be an honest replay).
|
|
69
|
+
*
|
|
70
|
+
* Pushes are **serialized per binding** (a FIFO chain): the DO rejects any push
|
|
71
|
+
* with `clientSeq > watermark + 1` as `OUT_OF_ORDER` and drops the write, so two
|
|
72
|
+
* mutators fired concurrently must not race the network into a gap. Each push
|
|
73
|
+
* waits for the previous one's ack and assigns its `clientSeq` *inside* the
|
|
74
|
+
* critical section — from the live watermark — so the sequence is always exactly
|
|
75
|
+
* `watermark + 1`. Because a failed mutation never advances the server watermark,
|
|
76
|
+
* a permanently-rejected predecessor can't wedge the chain: the next push simply
|
|
77
|
+
* reclaims the same `watermark + 1` instead of leaving a hole the DO waits on.
|
|
78
|
+
*/
|
|
79
|
+
declare const bindMutators: <M extends AnyMutatorMap>(client: LunoraClient, context: BindMutatorsContext, mutators: M) => BoundMutators<M>;
|
|
80
|
+
export { BindMutatorsContext as B, ClientMutatorContext as C, BoundMutators as a, ClientMutatorDef as b, bindMutators as c, defineMutator as d };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { LunoraClient } from '@lunora/client';
|
|
2
|
+
import { Collection, Transaction } from '@tanstack/db';
|
|
3
|
+
import { C as CheckpointRegistry, R as Row } from "./collection-options.d-lJBOVJgq.mjs";
|
|
4
|
+
/** The local store a client mutator's optimistic body writes against. */
|
|
5
|
+
interface ClientMutatorContext {
|
|
6
|
+
/** The wired collections, keyed by name — apply optimistic inserts/updates/deletes here. */
|
|
7
|
+
collections: Record<string, Collection<Row, string>>;
|
|
8
|
+
}
|
|
9
|
+
/** A client-side custom mutator: an optimistic body plus the path of its authoritative server impl. */
|
|
10
|
+
interface ClientMutatorDef<TArgs> {
|
|
11
|
+
/** Brand so codegen / `bindMutators` can recognize a mutator definition. */
|
|
12
|
+
__lunoraClientMutator: true;
|
|
13
|
+
/** The optimistic update applied to the local collections before the server confirms. */
|
|
14
|
+
apply: (context: ClientMutatorContext, args: TArgs) => void;
|
|
15
|
+
/** The Lunora function path of the server-authoritative mutator (`defineMutator` on the server). */
|
|
16
|
+
serverRef: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Declare a client-side custom mutator. `apply` runs optimistically against the
|
|
20
|
+
* local TanStack collections; `serverRef` names the authoritative server mutator
|
|
21
|
+
* the write is pushed to over the watermark protocol. The server impl is the
|
|
22
|
+
* linearization point — this body is a prediction the server can override.
|
|
23
|
+
*/
|
|
24
|
+
declare const defineMutator: <TArgs = Record<string, unknown>>(definition: {
|
|
25
|
+
apply: (context: ClientMutatorContext, args: TArgs) => void;
|
|
26
|
+
serverRef: string;
|
|
27
|
+
}) => ClientMutatorDef<TArgs>;
|
|
28
|
+
type AnyMutatorMap = Record<string, ClientMutatorDef<any>>;
|
|
29
|
+
/** Args type of a mutator definition. */
|
|
30
|
+
type ArgsOf<M> = M extends ClientMutatorDef<infer A> ? A : never;
|
|
31
|
+
/** Inputs `bindMutators` needs to run a mutator: the local store + how the overlay drops. */
|
|
32
|
+
interface BindMutatorsContext {
|
|
33
|
+
/**
|
|
34
|
+
* Resolves the optimistic-overlay drop against confirmed server watermarks.
|
|
35
|
+
* When supplied, a mutation's overlay is held until the sync stream echoes
|
|
36
|
+
* `lastMutationId >= clientSeq` (via {@link CheckpointRegistry.resolve}) — no
|
|
37
|
+
* flicker. When omitted, the overlay drops as soon as the server accepts the
|
|
38
|
+
* write (the by-value sync diff then converges the synced row in place).
|
|
39
|
+
*/
|
|
40
|
+
checkpoints?: CheckpointRegistry;
|
|
41
|
+
/** The wired collections the optimistic bodies write against. */
|
|
42
|
+
collections: Record<string, Collection<Row, string>>;
|
|
43
|
+
/** Optional shard key the mutator's server push is routed to. */
|
|
44
|
+
shardKey?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Calling a bound mutator runs the optimistic body + pushes the server write; returns the TanStack transaction. */
|
|
47
|
+
type BoundMutators<M extends AnyMutatorMap> = { [K in keyof M]: (args: ArgsOf<M[K]>) => Transaction };
|
|
48
|
+
/**
|
|
49
|
+
* Bind a set of client mutators to a client + local store. Each returned handle,
|
|
50
|
+
* when called, opens a TanStack optimistic transaction: the mutator's `apply`
|
|
51
|
+
* body writes the predicted rows into the collections, and the transaction's
|
|
52
|
+
* `mutationFn` pushes the authoritative write through
|
|
53
|
+
* {@link LunoraClient.callMutator} under a monotonic per-client `clientSeq`.
|
|
54
|
+
*
|
|
55
|
+
* Rebase-on-poke is free — TanStack DB re-derives every pending optimistic overlay
|
|
56
|
+
* over the latest synced base on each sync tick. The overlay is dropped when the
|
|
57
|
+
* server confirms the write (and, if `checkpoints` is supplied, once it echoes the
|
|
58
|
+
* matching watermark so the synced row has landed).
|
|
59
|
+
*
|
|
60
|
+
* The `clientSeq` generator is seeded from the server's echoed watermark
|
|
61
|
+
* ({@link LunoraClient.confirmedMutationWatermark}) on every issue, so a reload —
|
|
62
|
+
* which resets this in-memory counter while the server keeps a durable per-client
|
|
63
|
+
* watermark — never reissues a sequence the DO has already applied. As a backstop
|
|
64
|
+
* for the very first push of a fresh session (before any ack has taught the client
|
|
65
|
+
* the watermark), a push the DO swallows as a replay (`applied === false`) is
|
|
66
|
+
* reissued above the now-known watermark instead of being mistaken for a confirmed
|
|
67
|
+
* write — closing the silent-drop window without risking a double-apply (a fresh
|
|
68
|
+
* session's first stale push provably cannot be an honest replay).
|
|
69
|
+
*
|
|
70
|
+
* Pushes are **serialized per binding** (a FIFO chain): the DO rejects any push
|
|
71
|
+
* with `clientSeq > watermark + 1` as `OUT_OF_ORDER` and drops the write, so two
|
|
72
|
+
* mutators fired concurrently must not race the network into a gap. Each push
|
|
73
|
+
* waits for the previous one's ack and assigns its `clientSeq` *inside* the
|
|
74
|
+
* critical section — from the live watermark — so the sequence is always exactly
|
|
75
|
+
* `watermark + 1`. Because a failed mutation never advances the server watermark,
|
|
76
|
+
* a permanently-rejected predecessor can't wedge the chain: the next push simply
|
|
77
|
+
* reclaims the same `watermark + 1` instead of leaving a hole the DO waits on.
|
|
78
|
+
*/
|
|
79
|
+
declare const bindMutators: <M extends AnyMutatorMap>(client: LunoraClient, context: BindMutatorsContext, mutators: M) => BoundMutators<M>;
|
|
80
|
+
export { BindMutatorsContext as B, ClientMutatorContext as C, BoundMutators as a, ClientMutatorDef as b, bindMutators as c, defineMutator as d };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createCollection } from '@tanstack/db';
|
|
2
|
+
import { startOfflineExecutor, NonRetriableError } from '@tanstack/offline-transactions';
|
|
3
|
+
import { lunoraCollectionOptions } from './createCheckpointRegistry-SvwBczzv.mjs';
|
|
4
|
+
import { createOptimisticOnlineDetector, runOutboxMutation, OUTBOX_MUTATION_FN_NAME } from './OUTBOX_MUTATION_FN_NAME-Cf8iP6Wa.mjs';
|
|
5
|
+
|
|
6
|
+
const defineCollections = (client, defs, options = {}) => {
|
|
7
|
+
const collections = {};
|
|
8
|
+
const scope = {};
|
|
9
|
+
const mutationFns = {};
|
|
10
|
+
const entries = Object.entries(defs);
|
|
11
|
+
for (const [name, definition] of entries) {
|
|
12
|
+
const insert = definition.insert;
|
|
13
|
+
const { config, scope: scopeFunction } = lunoraCollectionOptions({
|
|
14
|
+
client,
|
|
15
|
+
getKey: definition.getKey,
|
|
16
|
+
id: name,
|
|
17
|
+
// `AnyDef` erases `list` to `any` (`TList = any`); it's a `FunctionReference` here.
|
|
18
|
+
list: definition.list,
|
|
19
|
+
...definition.load === void 0 ? {} : { load: definition.load },
|
|
20
|
+
onError: definition.onError,
|
|
21
|
+
scopeBy: definition.scopeBy
|
|
22
|
+
});
|
|
23
|
+
collections[name] = createCollection(config);
|
|
24
|
+
if (definition.scopeBy !== void 0) {
|
|
25
|
+
scope[name] = scopeFunction;
|
|
26
|
+
}
|
|
27
|
+
if (insert) {
|
|
28
|
+
mutationFns[name] = async ({ transaction }) => {
|
|
29
|
+
for (const mutation of transaction.mutations) {
|
|
30
|
+
const row = mutation.modified;
|
|
31
|
+
try {
|
|
32
|
+
await runOutboxMutation(() => client.mutation(insert.mutation, insert.toArgs(row)));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error instanceof NonRetriableError && options.onWriteRejected) {
|
|
35
|
+
try {
|
|
36
|
+
options.onWriteRejected({ code: error.code, collection: name, error, row });
|
|
37
|
+
} catch {
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
mutationFns[OUTBOX_MUTATION_FN_NAME] = async ({ transaction }) => {
|
|
47
|
+
const meta = transaction.metadata;
|
|
48
|
+
if (!meta) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (meta.identity !== client.currentIdentity()) {
|
|
52
|
+
throw new NonRetriableError("outbox write dropped: identity changed since it was queued");
|
|
53
|
+
}
|
|
54
|
+
await runOutboxMutation(
|
|
55
|
+
() => client.mutation({ __lunoraRef: meta.functionPath }, meta.args, { mutationId: meta.idempotencyKey, shardKey: meta.shardKey })
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
const executor = startOfflineExecutor({
|
|
59
|
+
collections,
|
|
60
|
+
mutationFns,
|
|
61
|
+
onlineDetector: createOptimisticOnlineDetector(),
|
|
62
|
+
...options.onLeadershipChange ? { onLeadershipChange: options.onLeadershipChange } : {},
|
|
63
|
+
...options.onStorageFailure ? { onStorageFailure: options.onStorageFailure } : {},
|
|
64
|
+
// A persisted write whose target collection was removed/renamed in a deploy
|
|
65
|
+
// hits an unregistered mutationFn. The executor drops it as a
|
|
66
|
+
// NonRetriableError *before* our per-collection `mutationFns` catch runs, so
|
|
67
|
+
// this hook is the only place to surface it on `onWriteRejected`.
|
|
68
|
+
onUnknownMutationFn: (name, tx) => {
|
|
69
|
+
try {
|
|
70
|
+
options.onWriteRejected?.({
|
|
71
|
+
code: "UNKNOWN_MUTATION_FN",
|
|
72
|
+
collection: name,
|
|
73
|
+
error: new Error(`offline write dropped: mutation "${name}" no longer exists (removed or renamed in a deploy?)`),
|
|
74
|
+
// Best-effort recovered row: the persisted `modified` shape isn't a
|
|
75
|
+
// validated `Row`, and a batched transaction surfaces only its first
|
|
76
|
+
// mutation's row. Enough to describe the dropped write to the user.
|
|
77
|
+
row: tx.mutations[0]?.modified
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
const actions = {};
|
|
84
|
+
for (const [name, definition] of entries) {
|
|
85
|
+
const insert = definition.insert;
|
|
86
|
+
const collection = collections[name];
|
|
87
|
+
if (!insert || !collection) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const action = executor.createOfflineAction({
|
|
91
|
+
mutationFnName: name,
|
|
92
|
+
onMutate: ({ id, input }) => {
|
|
93
|
+
collection.insert(insert.optimistic(input, id));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
actions[name] = (input) => {
|
|
97
|
+
const id = crypto.randomUUID();
|
|
98
|
+
const transaction = action({ id, input });
|
|
99
|
+
return { id, transaction };
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { actions, collections, executor, pendingCount: () => executor.getPendingCount(), scope };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export { defineCollections };
|
package/package.json
CHANGED
|
@@ -1,31 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/db",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "1.0.0-alpha.10",
|
|
4
4
|
"description": "TanStack DB binding: typed, live-synced collections and a durable offline outbox over the Lunora client",
|
|
5
|
-
"
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cloudflare",
|
|
7
|
+
"durable-objects",
|
|
8
|
+
"live-query",
|
|
9
|
+
"lunora",
|
|
10
|
+
"offline-first",
|
|
11
|
+
"optimistic-updates",
|
|
12
|
+
"tanstack-db",
|
|
13
|
+
"workers"
|
|
14
|
+
],
|
|
6
15
|
"homepage": "https://lunora.sh",
|
|
16
|
+
"bugs": "https://github.com/anolilab/lunora/issues",
|
|
17
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Daniel Bannert",
|
|
20
|
+
"email": "d.bannert@anolilab.de"
|
|
21
|
+
},
|
|
7
22
|
"repository": {
|
|
8
23
|
"type": "git",
|
|
9
24
|
"url": "git+https://github.com/anolilab/lunora.git",
|
|
10
25
|
"directory": "packages/db"
|
|
11
26
|
},
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
"cloudflare",
|
|
18
|
-
"workers",
|
|
19
|
-
"durable-objects",
|
|
20
|
-
"tanstack-db",
|
|
21
|
-
"offline-first",
|
|
22
|
-
"optimistic-updates",
|
|
23
|
-
"live-query"
|
|
27
|
+
"files": [
|
|
28
|
+
"./dist",
|
|
29
|
+
"__assets__",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE.md"
|
|
24
32
|
],
|
|
33
|
+
"type": "module",
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"main": "./dist/index.mjs",
|
|
36
|
+
"module": "./dist/index.mjs",
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"import": "./dist/index.mjs"
|
|
42
|
+
},
|
|
43
|
+
"./collections": {
|
|
44
|
+
"types": "./dist/collections/index.d.ts",
|
|
45
|
+
"import": "./dist/collections/index.mjs"
|
|
46
|
+
},
|
|
47
|
+
"./mutators": {
|
|
48
|
+
"types": "./dist/mutators/index.d.ts",
|
|
49
|
+
"import": "./dist/mutators/index.mjs"
|
|
50
|
+
},
|
|
51
|
+
"./package.json": "./package.json"
|
|
52
|
+
},
|
|
25
53
|
"publishConfig": {
|
|
26
54
|
"access": "public"
|
|
27
55
|
},
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@lunora/client": "1.0.0-alpha.10"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@tanstack/db": "^0.6.0",
|
|
61
|
+
"@tanstack/offline-transactions": "^1.0.0"
|
|
62
|
+
},
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": "^22.15.0 || >=24.11.0"
|
|
65
|
+
}
|
|
31
66
|
}
|