oxianjs 0.0.0-stage → 0.21.3
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 +21 -0
- package/package.json +26 -4
- package/src/host/host.d.ts +5 -0
- package/src/host/host.js +84 -0
- package/src/host/index.d.ts +3 -0
- package/src/host/index.js +2 -0
- package/src/host/types.d.ts +46 -0
- package/src/host/types.js +2 -0
- package/src/owner/index.d.ts +6 -0
- package/src/owner/index.js +4 -0
- package/src/owner/memory.d.ts +15 -0
- package/src/owner/memory.js +216 -0
- package/src/owner/owner.d.ts +3 -0
- package/src/owner/owner.js +462 -0
- package/src/owner/primitives.d.ts +9 -0
- package/src/owner/primitives.js +51 -0
- package/src/owner/remote.d.ts +44 -0
- package/src/owner/remote.js +175 -0
- package/src/owner/types.d.ts +203 -0
- package/src/owner/types.js +1 -0
- package/src/protocol/types.d.ts +168 -0
- package/src/protocol/types.js +8 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Oxian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxianjs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.21.3",
|
|
4
|
+
"description": "Oxian owners and the in-process host",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/AxionCompany/oxian-js.git"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=24"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
"./host": {
|
|
16
|
+
"types": "./src/host/index.d.ts",
|
|
17
|
+
"default": "./src/host/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./owner": {
|
|
20
|
+
"types": "./src/owner/index.d.ts",
|
|
21
|
+
"default": "./src/owner/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type HostOptions, type OwnerHost, type ShardMap } from "./types.ts";
|
|
2
|
+
/** Copies `owners` (owner id to shard id) into a map for this process's shard. */
|
|
3
|
+
export declare function createShardMap(shard: string, owners: Readonly<Record<string, string>>): ShardMap;
|
|
4
|
+
/** One process. It starts only the owners its shard map assigns here. */
|
|
5
|
+
export declare function createOwnerHost(options: HostOptions): OwnerHost;
|
package/src/host/host.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createOwnerError } from "../owner/primitives.js";
|
|
2
|
+
import { CONNECTION_BUDGET, } from "./types.js";
|
|
3
|
+
/** Copies `owners` (owner id to shard id) into a map for this process's shard. */
|
|
4
|
+
export function createShardMap(shard, owners) {
|
|
5
|
+
if (shard === "")
|
|
6
|
+
throw new TypeError("shard must be non-empty");
|
|
7
|
+
const assigned = new Map();
|
|
8
|
+
for (const [ownerId, shardId] of Object.entries(owners)) {
|
|
9
|
+
if (ownerId === "" || shardId === "") {
|
|
10
|
+
throw new TypeError("owner and shard ids must be non-empty");
|
|
11
|
+
}
|
|
12
|
+
assigned.set(ownerId, shardId);
|
|
13
|
+
}
|
|
14
|
+
return Object.freeze({
|
|
15
|
+
shard,
|
|
16
|
+
locate: (ownerId) => assigned.get(ownerId),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** One process. It starts only the owners its shard map assigns here. */
|
|
20
|
+
export function createOwnerHost(options) {
|
|
21
|
+
const { map } = options;
|
|
22
|
+
const budget = options.maxConnections ?? CONNECTION_BUDGET;
|
|
23
|
+
if (!Number.isSafeInteger(budget) ||
|
|
24
|
+
budget < 1 ||
|
|
25
|
+
budget > CONNECTION_BUDGET) {
|
|
26
|
+
throw new RangeError(`maxConnections must be from 1 to ${CONNECTION_BUDGET}`);
|
|
27
|
+
}
|
|
28
|
+
const running = new Map();
|
|
29
|
+
let connections = 0;
|
|
30
|
+
let closed = false;
|
|
31
|
+
function owns(ownerId) {
|
|
32
|
+
return map.locate(ownerId) === map.shard;
|
|
33
|
+
}
|
|
34
|
+
function open(ownerId, start) {
|
|
35
|
+
if (closed)
|
|
36
|
+
throw new Error("host is closed");
|
|
37
|
+
if (ownerId === "")
|
|
38
|
+
throw new TypeError("owner id must be non-empty");
|
|
39
|
+
if (!owns(ownerId)) {
|
|
40
|
+
throw createOwnerError("ownership_lost", "owner is not assigned to this host");
|
|
41
|
+
}
|
|
42
|
+
const existing = running.get(ownerId);
|
|
43
|
+
if (existing !== undefined)
|
|
44
|
+
return existing;
|
|
45
|
+
const started = start();
|
|
46
|
+
running.set(ownerId, started);
|
|
47
|
+
return started;
|
|
48
|
+
}
|
|
49
|
+
function connect() {
|
|
50
|
+
if (closed)
|
|
51
|
+
throw new Error("host is closed");
|
|
52
|
+
if (connections >= budget)
|
|
53
|
+
throw new Error("connection budget exceeded");
|
|
54
|
+
connections += 1;
|
|
55
|
+
let released = false;
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
release() {
|
|
58
|
+
if (released)
|
|
59
|
+
return;
|
|
60
|
+
released = true;
|
|
61
|
+
connections -= 1;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function snapshot() {
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
shard: map.shard,
|
|
68
|
+
owners: Object.freeze([...running.keys()]),
|
|
69
|
+
connections,
|
|
70
|
+
budget,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
map,
|
|
75
|
+
budget,
|
|
76
|
+
open,
|
|
77
|
+
connect,
|
|
78
|
+
owns,
|
|
79
|
+
snapshot,
|
|
80
|
+
close() {
|
|
81
|
+
closed = true;
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** How many connections one Cloud Run host may hold. */
|
|
2
|
+
export declare const CONNECTION_BUDGET = 1000;
|
|
3
|
+
/**
|
|
4
|
+
* A fixed assignment of owner ids to shards. One process is one shard.
|
|
5
|
+
* An id maps to one shard, so two processes cannot take the same owner.
|
|
6
|
+
*/
|
|
7
|
+
export type ShardMap = Readonly<{
|
|
8
|
+
shard: string;
|
|
9
|
+
/** The shard that owns `ownerId`, or undefined when this map does not know it. */
|
|
10
|
+
locate(ownerId: string): string | undefined;
|
|
11
|
+
}>;
|
|
12
|
+
export type HostOptions = Readonly<{
|
|
13
|
+
map: ShardMap;
|
|
14
|
+
/**
|
|
15
|
+
* Open connections this process accepts. Defaults to `CONNECTION_BUDGET`
|
|
16
|
+
* and cannot be higher.
|
|
17
|
+
*/
|
|
18
|
+
maxConnections?: number;
|
|
19
|
+
}>;
|
|
20
|
+
/** One accepted connection. `release` is safe to call twice. */
|
|
21
|
+
export type OwnerConnection = Readonly<{
|
|
22
|
+
release(): void;
|
|
23
|
+
}>;
|
|
24
|
+
export type HostSnapshot = Readonly<{
|
|
25
|
+
shard: string;
|
|
26
|
+
owners: readonly string[];
|
|
27
|
+
connections: number;
|
|
28
|
+
budget: number;
|
|
29
|
+
}>;
|
|
30
|
+
/** The owners assigned to this process, and the connections it still has room for. */
|
|
31
|
+
export type OwnerHost = Readonly<{
|
|
32
|
+
readonly map: ShardMap;
|
|
33
|
+
readonly budget: number;
|
|
34
|
+
/**
|
|
35
|
+
* Starts `ownerId` when this shard owns it. `start` is not called otherwise,
|
|
36
|
+
* so the epoch does not move. Opening an id twice returns the first instance.
|
|
37
|
+
*/
|
|
38
|
+
open<T>(ownerId: string, start: () => T): T;
|
|
39
|
+
/** Takes one connection. Throws when the budget is full. */
|
|
40
|
+
connect(): OwnerConnection;
|
|
41
|
+
/** True when this shard owns `ownerId`. */
|
|
42
|
+
owns(ownerId: string): boolean;
|
|
43
|
+
snapshot(): HostSnapshot;
|
|
44
|
+
/** Rejects later opens and connections. Does not stop owners already started. */
|
|
45
|
+
close(): void;
|
|
46
|
+
}>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createMemoryOwnerStore } from "./memory.ts";
|
|
2
|
+
export type { MemoryOwnerStore, MemoryTables } from "./memory.ts";
|
|
3
|
+
export { createOwner } from "./owner.ts";
|
|
4
|
+
export { createCommandSession, createSnapshotSession, SNAPSHOT_IDLE_MS, SNAPSHOT_LIFETIME_MS, } from "./remote.ts";
|
|
5
|
+
export { createOwnerError, isOwnerError } from "./primitives.ts";
|
|
6
|
+
export type * from "./types.ts";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createMemoryOwnerStore } from "./memory.js";
|
|
2
|
+
export { createOwner } from "./owner.js";
|
|
3
|
+
export { createCommandSession, createSnapshotSession, SNAPSHOT_IDLE_MS, SNAPSHOT_LIFETIME_MS, } from "./remote.js";
|
|
4
|
+
export { createOwnerError, isOwnerError } from "./primitives.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { JsonValue } from "../protocol/types.ts";
|
|
2
|
+
import type { OwnerStore } from "./types.ts";
|
|
3
|
+
/** Tables of JSON records, as seen by command handlers. */
|
|
4
|
+
export type MemoryTables = Readonly<{
|
|
5
|
+
get(table: string, id: string): JsonValue | undefined;
|
|
6
|
+
put(table: string, id: string, value: JsonValue): void;
|
|
7
|
+
delete(table: string, id: string): void;
|
|
8
|
+
list(table: string): readonly (readonly [string, JsonValue])[];
|
|
9
|
+
}>;
|
|
10
|
+
export type MemoryOwnerStore = OwnerStore<MemoryTables> & Readonly<{
|
|
11
|
+
/** Reads committed state, outside any command. */
|
|
12
|
+
read(table: string, id: string): Promise<JsonValue | undefined>;
|
|
13
|
+
}>;
|
|
14
|
+
/** One owner's state in process memory: for tests, the browser and the in-process host. */
|
|
15
|
+
export declare function createMemoryOwnerStore(): MemoryOwnerStore;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { createOwnerError, identityKey } from "./primitives.js";
|
|
2
|
+
/** One owner's state in process memory: for tests, the browser and the in-process host. */
|
|
3
|
+
export function createMemoryOwnerStore() {
|
|
4
|
+
const tables = new Map();
|
|
5
|
+
const receipts = new Map();
|
|
6
|
+
const changes = [];
|
|
7
|
+
let head = 0;
|
|
8
|
+
let alarm;
|
|
9
|
+
let alarmRevision = 0;
|
|
10
|
+
let epoch = 0;
|
|
11
|
+
let tail = Promise.resolve();
|
|
12
|
+
/** One transaction at a time; acquiring an epoch waits for the open one. */
|
|
13
|
+
function lock() {
|
|
14
|
+
let release;
|
|
15
|
+
const released = new Promise((resolve) => {
|
|
16
|
+
release = resolve;
|
|
17
|
+
});
|
|
18
|
+
const acquired = tail.then(() => release);
|
|
19
|
+
tail = tail.then(() => released);
|
|
20
|
+
return acquired;
|
|
21
|
+
}
|
|
22
|
+
function tableOf(name) {
|
|
23
|
+
let table = tables.get(name);
|
|
24
|
+
if (table === undefined) {
|
|
25
|
+
table = new Map();
|
|
26
|
+
tables.set(name, table);
|
|
27
|
+
}
|
|
28
|
+
return table;
|
|
29
|
+
}
|
|
30
|
+
async function begin(fenced) {
|
|
31
|
+
const release = await lock();
|
|
32
|
+
if (fenced !== epoch) {
|
|
33
|
+
release();
|
|
34
|
+
throw createOwnerError("ownership_lost", `epoch ${fenced} is no longer current (current ${epoch})`);
|
|
35
|
+
}
|
|
36
|
+
const undo = [];
|
|
37
|
+
let mark;
|
|
38
|
+
let finished = false;
|
|
39
|
+
const open = () => {
|
|
40
|
+
if (finished)
|
|
41
|
+
throw new TypeError("transaction has finished");
|
|
42
|
+
};
|
|
43
|
+
const write = (name, id, value) => {
|
|
44
|
+
open();
|
|
45
|
+
const table = tableOf(name);
|
|
46
|
+
const existed = table.has(id);
|
|
47
|
+
const previous = table.get(id);
|
|
48
|
+
undo.push(() => {
|
|
49
|
+
if (existed)
|
|
50
|
+
table.set(id, previous);
|
|
51
|
+
else
|
|
52
|
+
table.delete(id);
|
|
53
|
+
});
|
|
54
|
+
if (value === undefined)
|
|
55
|
+
table.delete(id);
|
|
56
|
+
else
|
|
57
|
+
table.set(id, structuredClone(value));
|
|
58
|
+
};
|
|
59
|
+
const unwind = (to) => {
|
|
60
|
+
while (undo.length > to)
|
|
61
|
+
undo.pop()();
|
|
62
|
+
};
|
|
63
|
+
const tx = Object.freeze({
|
|
64
|
+
get(name, id) {
|
|
65
|
+
open();
|
|
66
|
+
const value = tables.get(name)?.get(id);
|
|
67
|
+
return value === undefined ? undefined : structuredClone(value);
|
|
68
|
+
},
|
|
69
|
+
put: (name, id, value) => write(name, id, value),
|
|
70
|
+
delete: (name, id) => write(name, id),
|
|
71
|
+
list(name) {
|
|
72
|
+
open();
|
|
73
|
+
return [...(tables.get(name) ?? [])].map(([id, value]) => [id, structuredClone(value)]);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
return Object.freeze({
|
|
77
|
+
tx,
|
|
78
|
+
savepoint() {
|
|
79
|
+
open();
|
|
80
|
+
mark = undo.length;
|
|
81
|
+
return Promise.resolve();
|
|
82
|
+
},
|
|
83
|
+
release() {
|
|
84
|
+
open();
|
|
85
|
+
mark = undefined;
|
|
86
|
+
return Promise.resolve();
|
|
87
|
+
},
|
|
88
|
+
rollbackToSavepoint() {
|
|
89
|
+
open();
|
|
90
|
+
if (mark === undefined)
|
|
91
|
+
throw new TypeError("no open savepoint");
|
|
92
|
+
unwind(mark);
|
|
93
|
+
mark = undefined;
|
|
94
|
+
return Promise.resolve();
|
|
95
|
+
},
|
|
96
|
+
readReceipt(identity) {
|
|
97
|
+
open();
|
|
98
|
+
return Promise.resolve(copyReceipt(receipts.get(identityKey(identity))));
|
|
99
|
+
},
|
|
100
|
+
writeReceipt(receipt) {
|
|
101
|
+
open();
|
|
102
|
+
const key = identityKey(receipt.identity);
|
|
103
|
+
const previous = receipts.get(key);
|
|
104
|
+
undo.push(() => {
|
|
105
|
+
if (previous === undefined)
|
|
106
|
+
receipts.delete(key);
|
|
107
|
+
else
|
|
108
|
+
receipts.set(key, previous);
|
|
109
|
+
});
|
|
110
|
+
receipts.set(key, structuredClone(receipt));
|
|
111
|
+
return Promise.resolve();
|
|
112
|
+
},
|
|
113
|
+
append(body) {
|
|
114
|
+
open();
|
|
115
|
+
const seq = head + 1;
|
|
116
|
+
const previousHead = head;
|
|
117
|
+
undo.push(() => {
|
|
118
|
+
head = previousHead;
|
|
119
|
+
changes.pop();
|
|
120
|
+
});
|
|
121
|
+
head = seq;
|
|
122
|
+
changes.push(structuredClone(body));
|
|
123
|
+
return Promise.resolve(seq);
|
|
124
|
+
},
|
|
125
|
+
head() {
|
|
126
|
+
open();
|
|
127
|
+
return Promise.resolve(head);
|
|
128
|
+
},
|
|
129
|
+
setAlarm(at) {
|
|
130
|
+
open();
|
|
131
|
+
const previous = alarm;
|
|
132
|
+
const previousRevision = alarmRevision;
|
|
133
|
+
undo.push(() => {
|
|
134
|
+
alarm = previous;
|
|
135
|
+
alarmRevision = previousRevision;
|
|
136
|
+
});
|
|
137
|
+
if (at === undefined)
|
|
138
|
+
alarm = undefined;
|
|
139
|
+
else {
|
|
140
|
+
alarmRevision += 1;
|
|
141
|
+
alarm = { at, revision: alarmRevision };
|
|
142
|
+
}
|
|
143
|
+
return Promise.resolve();
|
|
144
|
+
},
|
|
145
|
+
commit() {
|
|
146
|
+
open();
|
|
147
|
+
finished = true;
|
|
148
|
+
release();
|
|
149
|
+
return Promise.resolve();
|
|
150
|
+
},
|
|
151
|
+
rollback() {
|
|
152
|
+
open();
|
|
153
|
+
unwind(0);
|
|
154
|
+
finished = true;
|
|
155
|
+
release();
|
|
156
|
+
return Promise.resolve();
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async function committed(read) {
|
|
161
|
+
const release = await lock();
|
|
162
|
+
try {
|
|
163
|
+
return read();
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
release();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return Object.freeze({
|
|
170
|
+
async acquire() {
|
|
171
|
+
return await committed(() => ++epoch);
|
|
172
|
+
},
|
|
173
|
+
begin,
|
|
174
|
+
readReceipt: (identity) => committed(() => copyReceipt(receipts.get(identityKey(identity)))),
|
|
175
|
+
readHead: () => committed(() => head),
|
|
176
|
+
readAlarm: () => committed(() => (alarm === undefined ? undefined : { ...alarm })),
|
|
177
|
+
readEpoch: () => committed(() => epoch),
|
|
178
|
+
beginSnapshot: () => committed(() => {
|
|
179
|
+
const copy = new Map();
|
|
180
|
+
for (const [name, table] of tables) {
|
|
181
|
+
const cloned = new Map();
|
|
182
|
+
for (const [id, value] of table) {
|
|
183
|
+
cloned.set(id, structuredClone(value));
|
|
184
|
+
}
|
|
185
|
+
copy.set(name, cloned);
|
|
186
|
+
}
|
|
187
|
+
const tx = Object.freeze({
|
|
188
|
+
get(name, id) {
|
|
189
|
+
const value = copy.get(name)?.get(id);
|
|
190
|
+
return value === undefined ? undefined : structuredClone(value);
|
|
191
|
+
},
|
|
192
|
+
put() {
|
|
193
|
+
throw new Error("snapshot is read-only");
|
|
194
|
+
},
|
|
195
|
+
delete() {
|
|
196
|
+
throw new Error("snapshot is read-only");
|
|
197
|
+
},
|
|
198
|
+
list(name) {
|
|
199
|
+
return [...(copy.get(name) ?? [])].map(([id, value]) => [id, structuredClone(value)]);
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
const snapshot = Object.freeze({
|
|
203
|
+
tx,
|
|
204
|
+
close: () => Promise.resolve(),
|
|
205
|
+
});
|
|
206
|
+
return snapshot;
|
|
207
|
+
}),
|
|
208
|
+
read: (table, id) => committed(() => {
|
|
209
|
+
const value = tables.get(table)?.get(id);
|
|
210
|
+
return value === undefined ? undefined : structuredClone(value);
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
function copyReceipt(receipt) {
|
|
215
|
+
return receipt === undefined ? undefined : structuredClone(receipt);
|
|
216
|
+
}
|