@korajs/server 1.0.0-beta.0 → 1.0.0-beta.2
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/dist/index.cjs +586 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +509 -179
- package/dist/index.d.ts +509 -179
- package/dist/index.js +568 -49
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -54,11 +54,17 @@ function createDefaultLogger() {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// src/apply/rejection-taxonomy.ts
|
|
58
|
+
var RETRIABLE_REJECTION_CODES = /* @__PURE__ */ new Set(["RATE_LIMIT"]);
|
|
59
|
+
function isRetriableRejection(code) {
|
|
60
|
+
return RETRIABLE_REJECTION_CODES.has(code);
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
// src/store/memory-server-store.ts
|
|
58
64
|
import { generateUUIDv7 } from "@korajs/core";
|
|
59
65
|
|
|
60
66
|
// src/store/materialization.ts
|
|
61
|
-
import { StorageError } from "@korajs/core";
|
|
67
|
+
import { StorageError, quoteIdent } from "@korajs/core";
|
|
62
68
|
function fieldTypeToSql(descriptor, dialect) {
|
|
63
69
|
switch (descriptor.kind) {
|
|
64
70
|
case "string":
|
|
@@ -73,6 +79,14 @@ function fieldTypeToSql(descriptor, dialect) {
|
|
|
73
79
|
return dialect === "postgres" ? "BIGINT" : "INTEGER";
|
|
74
80
|
case "array":
|
|
75
81
|
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
82
|
+
case "object":
|
|
83
|
+
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
84
|
+
case "json":
|
|
85
|
+
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
86
|
+
case "blob":
|
|
87
|
+
return "TEXT";
|
|
88
|
+
case "secret":
|
|
89
|
+
return "TEXT";
|
|
76
90
|
case "richtext":
|
|
77
91
|
return dialect === "postgres" ? "BYTEA" : "BLOB";
|
|
78
92
|
}
|
|
@@ -89,13 +103,13 @@ function generateCollectionDDL(name, collection, dialect) {
|
|
|
89
103
|
const columns = ["id TEXT PRIMARY KEY NOT NULL"];
|
|
90
104
|
for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
|
|
91
105
|
const sqlType = fieldTypeToSql(descriptor, dialect);
|
|
92
|
-
let colDef = `${fieldName} ${sqlType}`;
|
|
106
|
+
let colDef = `${quoteIdent(fieldName)} ${sqlType}`;
|
|
93
107
|
if (descriptor.defaultValue !== void 0) {
|
|
94
108
|
colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
|
|
95
109
|
}
|
|
96
110
|
if (descriptor.kind === "enum" && descriptor.enumValues) {
|
|
97
111
|
const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
|
|
98
|
-
colDef += ` CHECK (${fieldName} IN (${values}))`;
|
|
112
|
+
colDef += ` CHECK (${quoteIdent(fieldName)} IN (${values}))`;
|
|
99
113
|
}
|
|
100
114
|
columns.push(colDef);
|
|
101
115
|
}
|
|
@@ -103,24 +117,26 @@ function generateCollectionDDL(name, collection, dialect) {
|
|
|
103
117
|
columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
|
|
104
118
|
columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
|
|
105
119
|
columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
|
|
106
|
-
statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
|
|
120
|
+
statements.push(`CREATE TABLE IF NOT EXISTS ${quoteIdent(name)} (
|
|
107
121
|
${columns.join(",\n ")}
|
|
108
122
|
)`);
|
|
109
123
|
for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
|
|
110
124
|
const sqlType = fieldTypeToSql(descriptor, dialect);
|
|
111
|
-
let colDef = `${fieldName} ${sqlType}`;
|
|
125
|
+
let colDef = `${quoteIdent(fieldName)} ${sqlType}`;
|
|
112
126
|
if (descriptor.defaultValue !== void 0) {
|
|
113
127
|
colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
|
|
114
128
|
}
|
|
115
129
|
statements.push(`--kora:safe-alter
|
|
116
|
-
ALTER TABLE ${name} ADD COLUMN ${colDef}`);
|
|
130
|
+
ALTER TABLE ${quoteIdent(name)} ADD COLUMN ${colDef}`);
|
|
117
131
|
}
|
|
118
132
|
for (const indexField of collection.indexes) {
|
|
119
133
|
statements.push(
|
|
120
|
-
`CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
|
|
134
|
+
`CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${quoteIdent(name)} (${quoteIdent(indexField)})`
|
|
121
135
|
);
|
|
122
136
|
}
|
|
123
|
-
statements.push(
|
|
137
|
+
statements.push(
|
|
138
|
+
`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${quoteIdent(name)} (_deleted)`
|
|
139
|
+
);
|
|
124
140
|
return statements;
|
|
125
141
|
}
|
|
126
142
|
function generateAllCollectionDDL(schema, dialect) {
|
|
@@ -590,7 +606,7 @@ var MemoryServerStore = class {
|
|
|
590
606
|
};
|
|
591
607
|
|
|
592
608
|
// src/store/postgres-server-store.ts
|
|
593
|
-
import { generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
|
|
609
|
+
import { generateUUIDv7 as generateUUIDv72, quoteIdent as quoteIdent2 } from "@korajs/core";
|
|
594
610
|
import { and, asc, between, count, eq, sql } from "drizzle-orm";
|
|
595
611
|
|
|
596
612
|
// src/store/drizzle-pg-schema.ts
|
|
@@ -754,7 +770,7 @@ var PostgresServerStore = class {
|
|
|
754
770
|
this.assertCollection(collection);
|
|
755
771
|
const schema = this.schema;
|
|
756
772
|
const collectionDef = schema.collections[collection];
|
|
757
|
-
const query = sql`SELECT * FROM ${sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
773
|
+
const query = sql`SELECT * FROM ${sql.raw(quoteIdent2(collection))} WHERE id = ${id} AND _deleted = 0`;
|
|
758
774
|
const rows = await this.db.execute(query);
|
|
759
775
|
if (rows.length === 0) return null;
|
|
760
776
|
return this.deserializeRow(rows[0], collectionDef);
|
|
@@ -771,7 +787,7 @@ var PostgresServerStore = class {
|
|
|
771
787
|
}
|
|
772
788
|
}
|
|
773
789
|
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
774
|
-
const query = sql`SELECT COUNT(*) as cnt FROM ${sql.raw(collection)} WHERE ${whereClause}`;
|
|
790
|
+
const query = sql`SELECT COUNT(*) as cnt FROM ${sql.raw(quoteIdent2(collection))} WHERE ${whereClause}`;
|
|
775
791
|
const rows = await this.db.execute(query);
|
|
776
792
|
const cnt = rows[0]?.cnt;
|
|
777
793
|
return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
|
|
@@ -858,7 +874,7 @@ var PostgresServerStore = class {
|
|
|
858
874
|
);
|
|
859
875
|
} else {
|
|
860
876
|
await txOrDb.execute(
|
|
861
|
-
sql`UPDATE ${sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
877
|
+
sql`UPDATE ${sql.raw(quoteIdent2(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
862
878
|
);
|
|
863
879
|
}
|
|
864
880
|
}
|
|
@@ -877,16 +893,16 @@ var PostgresServerStore = class {
|
|
|
877
893
|
updatedAt,
|
|
878
894
|
0
|
|
879
895
|
];
|
|
880
|
-
const columnsSql = sql.raw(allColumns.join(", "));
|
|
896
|
+
const columnsSql = sql.raw(allColumns.map((c) => quoteIdent2(c)).join(", "));
|
|
881
897
|
const valuesSql = sql.join(
|
|
882
898
|
values.map((v) => sql`${v}`),
|
|
883
899
|
sql.raw(", ")
|
|
884
900
|
);
|
|
885
901
|
const updateSet = sql.raw(
|
|
886
|
-
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
902
|
+
allColumns.slice(1).map((c) => `${quoteIdent2(c)} = excluded.${quoteIdent2(c)}`).join(", ")
|
|
887
903
|
);
|
|
888
904
|
await txOrDb.execute(
|
|
889
|
-
sql`INSERT INTO ${sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
905
|
+
sql`INSERT INTO ${sql.raw(quoteIdent2(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
890
906
|
);
|
|
891
907
|
}
|
|
892
908
|
/**
|
|
@@ -946,7 +962,7 @@ var PostgresServerStore = class {
|
|
|
946
962
|
);
|
|
947
963
|
} else {
|
|
948
964
|
await this.db.execute(
|
|
949
|
-
sql`INSERT INTO ${sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
965
|
+
sql`INSERT INTO ${sql.raw(quoteIdent2(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
950
966
|
);
|
|
951
967
|
}
|
|
952
968
|
}
|
|
@@ -959,10 +975,12 @@ var PostgresServerStore = class {
|
|
|
959
975
|
options?.where ?? {},
|
|
960
976
|
options?.includeDeleted ?? false
|
|
961
977
|
);
|
|
962
|
-
const parts = [
|
|
978
|
+
const parts = [
|
|
979
|
+
sql`SELECT * FROM ${sql.raw(quoteIdent2(collection))} WHERE ${whereClause}`
|
|
980
|
+
];
|
|
963
981
|
if (options?.orderBy) {
|
|
964
982
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
965
|
-
parts.push(sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
983
|
+
parts.push(sql.raw(` ORDER BY ${quoteIdent2(options.orderBy)} ${dir}`));
|
|
966
984
|
}
|
|
967
985
|
if (options?.limit !== void 0) {
|
|
968
986
|
parts.push(sql` LIMIT ${options.limit}`);
|
|
@@ -978,7 +996,7 @@ var PostgresServerStore = class {
|
|
|
978
996
|
conditions.push(sql.raw("_deleted = 0"));
|
|
979
997
|
}
|
|
980
998
|
for (const [key, value] of Object.entries(where)) {
|
|
981
|
-
conditions.push(sql`${sql.raw(key)} = ${value}`);
|
|
999
|
+
conditions.push(sql`${sql.raw(quoteIdent2(key))} = ${value}`);
|
|
982
1000
|
}
|
|
983
1001
|
if (conditions.length === 0) {
|
|
984
1002
|
return sql.raw("1 = 1");
|
|
@@ -1173,7 +1191,7 @@ async function loadPostgresDeps() {
|
|
|
1173
1191
|
|
|
1174
1192
|
// src/store/sqlite-server-store.ts
|
|
1175
1193
|
import { createRequire } from "module";
|
|
1176
|
-
import { generateUUIDv7 as generateUUIDv73 } from "@korajs/core";
|
|
1194
|
+
import { generateUUIDv7 as generateUUIDv73, quoteIdent as quoteIdent3 } from "@korajs/core";
|
|
1177
1195
|
import { and as and2, asc as asc2, between as between2, count as count2, eq as eq2, sql as sql2 } from "drizzle-orm";
|
|
1178
1196
|
|
|
1179
1197
|
// src/store/drizzle-schema.ts
|
|
@@ -1328,7 +1346,7 @@ var SqliteServerStore = class {
|
|
|
1328
1346
|
this.assertCollection(collection);
|
|
1329
1347
|
const schema = this.schema;
|
|
1330
1348
|
const collectionDef = schema.collections[collection];
|
|
1331
|
-
const query = sql2`SELECT * FROM ${sql2.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
1349
|
+
const query = sql2`SELECT * FROM ${sql2.raw(quoteIdent3(collection))} WHERE id = ${id} AND _deleted = 0`;
|
|
1332
1350
|
const rows = this.db.all(query);
|
|
1333
1351
|
if (rows.length === 0) return null;
|
|
1334
1352
|
return this.deserializeRow(rows[0], collectionDef);
|
|
@@ -1344,7 +1362,7 @@ var SqliteServerStore = class {
|
|
|
1344
1362
|
}
|
|
1345
1363
|
}
|
|
1346
1364
|
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
1347
|
-
const query = sql2`SELECT COUNT(*) as cnt FROM ${sql2.raw(collection)} WHERE ${whereClause}`;
|
|
1365
|
+
const query = sql2`SELECT COUNT(*) as cnt FROM ${sql2.raw(quoteIdent3(collection))} WHERE ${whereClause}`;
|
|
1348
1366
|
const rows = this.db.all(query);
|
|
1349
1367
|
return rows[0]?.cnt ?? 0;
|
|
1350
1368
|
}
|
|
@@ -1421,7 +1439,7 @@ var SqliteServerStore = class {
|
|
|
1421
1439
|
);
|
|
1422
1440
|
} else {
|
|
1423
1441
|
txOrDb.run(
|
|
1424
|
-
sql2`UPDATE ${sql2.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1442
|
+
sql2`UPDATE ${sql2.raw(quoteIdent3(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1425
1443
|
);
|
|
1426
1444
|
}
|
|
1427
1445
|
}
|
|
@@ -1442,16 +1460,16 @@ var SqliteServerStore = class {
|
|
|
1442
1460
|
0
|
|
1443
1461
|
// _deleted = false
|
|
1444
1462
|
];
|
|
1445
|
-
const columnsSql = sql2.raw(allColumns.join(", "));
|
|
1463
|
+
const columnsSql = sql2.raw(allColumns.map((c) => quoteIdent3(c)).join(", "));
|
|
1446
1464
|
const valuesSql = sql2.join(
|
|
1447
1465
|
values.map((v) => sql2`${v}`),
|
|
1448
1466
|
sql2.raw(", ")
|
|
1449
1467
|
);
|
|
1450
1468
|
const updateSet = sql2.raw(
|
|
1451
|
-
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
1469
|
+
allColumns.slice(1).map((c) => `${quoteIdent3(c)} = excluded.${quoteIdent3(c)}`).join(", ")
|
|
1452
1470
|
);
|
|
1453
1471
|
txOrDb.run(
|
|
1454
|
-
sql2`INSERT INTO ${sql2.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1472
|
+
sql2`INSERT INTO ${sql2.raw(quoteIdent3(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1455
1473
|
);
|
|
1456
1474
|
}
|
|
1457
1475
|
/**
|
|
@@ -1509,7 +1527,7 @@ var SqliteServerStore = class {
|
|
|
1509
1527
|
);
|
|
1510
1528
|
} else {
|
|
1511
1529
|
tx.run(
|
|
1512
|
-
sql2`INSERT INTO ${sql2.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
1530
|
+
sql2`INSERT INTO ${sql2.raw(quoteIdent3(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
1513
1531
|
);
|
|
1514
1532
|
}
|
|
1515
1533
|
}
|
|
@@ -1523,10 +1541,12 @@ var SqliteServerStore = class {
|
|
|
1523
1541
|
options?.where ?? {},
|
|
1524
1542
|
options?.includeDeleted ?? false
|
|
1525
1543
|
);
|
|
1526
|
-
const parts = [
|
|
1544
|
+
const parts = [
|
|
1545
|
+
sql2`SELECT * FROM ${sql2.raw(quoteIdent3(collection))} WHERE ${whereClause}`
|
|
1546
|
+
];
|
|
1527
1547
|
if (options?.orderBy) {
|
|
1528
1548
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
1529
|
-
parts.push(sql2.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
1549
|
+
parts.push(sql2.raw(` ORDER BY ${quoteIdent3(options.orderBy)} ${dir}`));
|
|
1530
1550
|
}
|
|
1531
1551
|
if (options?.limit !== void 0) {
|
|
1532
1552
|
parts.push(sql2` LIMIT ${options.limit}`);
|
|
@@ -1542,7 +1562,7 @@ var SqliteServerStore = class {
|
|
|
1542
1562
|
conditions.push(sql2.raw("_deleted = 0"));
|
|
1543
1563
|
}
|
|
1544
1564
|
for (const [key, value] of Object.entries(where)) {
|
|
1545
|
-
conditions.push(sql2`${sql2.raw(key)} = ${value}`);
|
|
1565
|
+
conditions.push(sql2`${sql2.raw(quoteIdent3(key))} = ${value}`);
|
|
1546
1566
|
}
|
|
1547
1567
|
if (conditions.length === 0) {
|
|
1548
1568
|
return sql2.raw("1 = 1");
|
|
@@ -1861,8 +1881,9 @@ var WsServerTransport = class {
|
|
|
1861
1881
|
};
|
|
1862
1882
|
|
|
1863
1883
|
// src/session/client-session.ts
|
|
1864
|
-
import { generateUUIDv7 as generateUUIDv74 } from "@korajs/core";
|
|
1884
|
+
import { generateUUIDv7 as generateUUIDv74, hashBlob } from "@korajs/core";
|
|
1865
1885
|
import { topologicalSort } from "@korajs/core/internal";
|
|
1886
|
+
import { decodeBlobChunkBytes } from "@korajs/sync";
|
|
1866
1887
|
import {
|
|
1867
1888
|
NegotiatedMessageSerializer,
|
|
1868
1889
|
SCHEMA_MISMATCH_PREFIX,
|
|
@@ -1992,12 +2013,14 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
1992
2013
|
const lookup = relationLookup ?? (schema ? buildMergeRelationLookup(schema) : /* @__PURE__ */ new Map());
|
|
1993
2014
|
const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
|
|
1994
2015
|
if (!constraintCheck.valid) {
|
|
2016
|
+
const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
|
|
1995
2017
|
return {
|
|
1996
2018
|
result: "skipped",
|
|
1997
2019
|
appliedOperations: [],
|
|
1998
2020
|
rejection: {
|
|
1999
|
-
code
|
|
2000
|
-
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint
|
|
2021
|
+
code,
|
|
2022
|
+
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`,
|
|
2023
|
+
retriable: isRetriableRejection(code)
|
|
2001
2024
|
}
|
|
2002
2025
|
};
|
|
2003
2026
|
}
|
|
@@ -2010,7 +2033,8 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2010
2033
|
appliedOperations: [],
|
|
2011
2034
|
rejection: {
|
|
2012
2035
|
code: "REFERENTIAL_INTEGRITY",
|
|
2013
|
-
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"
|
|
2036
|
+
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`,
|
|
2037
|
+
retriable: isRetriableRejection("REFERENTIAL_INTEGRITY")
|
|
2014
2038
|
}
|
|
2015
2039
|
};
|
|
2016
2040
|
}
|
|
@@ -2043,6 +2067,13 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2043
2067
|
};
|
|
2044
2068
|
}
|
|
2045
2069
|
|
|
2070
|
+
// src/auth/no-auth.ts
|
|
2071
|
+
var NoAuthProvider = class {
|
|
2072
|
+
async authenticate(_token) {
|
|
2073
|
+
return { userId: "anonymous" };
|
|
2074
|
+
}
|
|
2075
|
+
};
|
|
2076
|
+
|
|
2046
2077
|
// src/scopes/resolve-session-scopes.ts
|
|
2047
2078
|
import { buildScopeMap } from "@korajs/core";
|
|
2048
2079
|
function resolveSessionScopes(schema, options) {
|
|
@@ -2168,6 +2199,25 @@ var SessionRateLimiter = class {
|
|
|
2168
2199
|
// src/session/client-session.ts
|
|
2169
2200
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2170
2201
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2202
|
+
var warnedUnscopedProviders = /* @__PURE__ */ new WeakSet();
|
|
2203
|
+
function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
2204
|
+
if (!auth || auth instanceof NoAuthProvider) {
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
if (resolvedScopes) {
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
if (!schema || Object.keys(schema.collections).length === 0) {
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
if (warnedUnscopedProviders.has(auth)) {
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
warnedUnscopedProviders.add(auth);
|
|
2217
|
+
console.warn(
|
|
2218
|
+
"[kora] An authenticated session resolved to no sync scopes, so every user will sync every other user's data. Return per-user sync scopes from your auth provider (for example KoraAuthProvider's resolveScopes) to isolate tenants. Note: declaring sync rules in your schema is not enough on its own \u2014 the per-user values come from the auth provider. (This warning is expected for single-tenant apps where all authenticated users are meant to share the same data.)"
|
|
2219
|
+
);
|
|
2220
|
+
}
|
|
2171
2221
|
var ClientSession = class {
|
|
2172
2222
|
state = "connected";
|
|
2173
2223
|
clientNodeId = null;
|
|
@@ -2186,10 +2236,15 @@ var ClientSession = class {
|
|
|
2186
2236
|
onRelay;
|
|
2187
2237
|
onAwarenessUpdate;
|
|
2188
2238
|
onYjsDocUpdate;
|
|
2239
|
+
onBlobChunkRequest;
|
|
2240
|
+
onBlobChunkResponse;
|
|
2241
|
+
persistBlobChunk;
|
|
2189
2242
|
onClose;
|
|
2190
2243
|
maxOperationBytes;
|
|
2191
2244
|
maxOpsPerMinute;
|
|
2192
2245
|
rateLimiter;
|
|
2246
|
+
validateOperation;
|
|
2247
|
+
koraContext;
|
|
2193
2248
|
constructor(options) {
|
|
2194
2249
|
this.sessionId = options.sessionId;
|
|
2195
2250
|
this.transport = options.transport;
|
|
@@ -2207,10 +2262,15 @@ var ClientSession = class {
|
|
|
2207
2262
|
this.onRelay = options.onRelay ?? null;
|
|
2208
2263
|
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2209
2264
|
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
2265
|
+
this.onBlobChunkRequest = options.onBlobChunkRequest ?? null;
|
|
2266
|
+
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2267
|
+
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2210
2268
|
this.onClose = options.onClose ?? null;
|
|
2211
2269
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2212
2270
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
2213
2271
|
this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
|
|
2272
|
+
this.validateOperation = options.validateOperation ?? null;
|
|
2273
|
+
this.koraContext = options.koraContext ?? null;
|
|
2214
2274
|
}
|
|
2215
2275
|
/**
|
|
2216
2276
|
* Start handling messages from the client transport.
|
|
@@ -2312,8 +2372,33 @@ var ClientSession = class {
|
|
|
2312
2372
|
case "yjs-doc-update":
|
|
2313
2373
|
this.handleYjsDocUpdate(message);
|
|
2314
2374
|
break;
|
|
2375
|
+
case "blob-chunk-request":
|
|
2376
|
+
this.onBlobChunkRequest?.(this.sessionId, message);
|
|
2377
|
+
break;
|
|
2378
|
+
case "blob-chunk-response":
|
|
2379
|
+
this.onBlobChunkResponse?.(this.sessionId, message);
|
|
2380
|
+
break;
|
|
2381
|
+
case "blob-chunk-push":
|
|
2382
|
+
await this.handleBlobChunkPush(message);
|
|
2383
|
+
break;
|
|
2315
2384
|
}
|
|
2316
2385
|
}
|
|
2386
|
+
/**
|
|
2387
|
+
* Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
|
|
2388
|
+
* verified to hash to the declared hash before storing, so a corrupt or
|
|
2389
|
+
* mislabeled upload is rejected rather than served later as trusted content.
|
|
2390
|
+
*/
|
|
2391
|
+
async handleBlobChunkPush(message) {
|
|
2392
|
+
if (!this.persistBlobChunk) {
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
const bytes = decodeBlobChunkBytes(message.bytes);
|
|
2396
|
+
const actual = await hashBlob(bytes);
|
|
2397
|
+
if (actual !== message.hash) {
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
await this.persistBlobChunk(message.hash, bytes);
|
|
2401
|
+
}
|
|
2317
2402
|
handleMessageFailure(error) {
|
|
2318
2403
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
2319
2404
|
this.sendError("SYNC_ERROR", reason, true);
|
|
@@ -2347,6 +2432,7 @@ var ClientSession = class {
|
|
|
2347
2432
|
this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
|
|
2348
2433
|
}
|
|
2349
2434
|
}
|
|
2435
|
+
warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
|
|
2350
2436
|
if (msg.syncQueries && msg.syncQueries.length > 0) {
|
|
2351
2437
|
this.syncQuerySubsets = dedupeQuerySubsets(msg.syncQueries);
|
|
2352
2438
|
} else {
|
|
@@ -2383,6 +2469,9 @@ var ClientSession = class {
|
|
|
2383
2469
|
accepted: true,
|
|
2384
2470
|
selectedWireFormat,
|
|
2385
2471
|
serverTime: Date.now(),
|
|
2472
|
+
// Advertise central blob storage so the client uploads the bytes behind
|
|
2473
|
+
// its blob fields, keeping them available after the author goes offline.
|
|
2474
|
+
...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
|
|
2386
2475
|
// Confirm the accepted scope so the client knows what data will be synced.
|
|
2387
2476
|
// This may differ from what the client requested if auth scopes are narrower.
|
|
2388
2477
|
...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
|
|
@@ -2428,10 +2517,39 @@ var ClientSession = class {
|
|
|
2428
2517
|
);
|
|
2429
2518
|
continue;
|
|
2430
2519
|
}
|
|
2520
|
+
if (this.validateOperation && this.koraContext) {
|
|
2521
|
+
let decision;
|
|
2522
|
+
try {
|
|
2523
|
+
decision = await this.validateOperation(op, {
|
|
2524
|
+
auth: this.authContext,
|
|
2525
|
+
kora: this.koraContext
|
|
2526
|
+
});
|
|
2527
|
+
} catch (error) {
|
|
2528
|
+
const message = error instanceof Error ? error.message : "validator error";
|
|
2529
|
+
this.sendOperationRejected(op, "VALIDATION_ERROR", `Validator threw: ${message}`, true);
|
|
2530
|
+
continue;
|
|
2531
|
+
}
|
|
2532
|
+
if (decision.action === "reject") {
|
|
2533
|
+
this.sendOperationRejected(
|
|
2534
|
+
op,
|
|
2535
|
+
decision.code,
|
|
2536
|
+
decision.message,
|
|
2537
|
+
decision.retriable ?? isRetriableRejection(decision.code)
|
|
2538
|
+
);
|
|
2539
|
+
continue;
|
|
2540
|
+
}
|
|
2541
|
+
if (decision.action === "ignore") {
|
|
2542
|
+
continue;
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2431
2545
|
try {
|
|
2432
2546
|
const applyResult = await applyServerOperation(this.store, op);
|
|
2433
2547
|
if (applyResult.rejection) {
|
|
2434
|
-
this.sendError(
|
|
2548
|
+
this.sendError(
|
|
2549
|
+
applyResult.rejection.code,
|
|
2550
|
+
applyResult.rejection.message,
|
|
2551
|
+
applyResult.rejection.retriable
|
|
2552
|
+
);
|
|
2435
2553
|
continue;
|
|
2436
2554
|
}
|
|
2437
2555
|
if (applyResult.result === "applied") {
|
|
@@ -2550,6 +2668,25 @@ var ClientSession = class {
|
|
|
2550
2668
|
};
|
|
2551
2669
|
this.sendToClient(errorMsg);
|
|
2552
2670
|
}
|
|
2671
|
+
/**
|
|
2672
|
+
* Reject one specific client operation, tied to its id, so the submitter can
|
|
2673
|
+
* divert it out of its pending queue into a durable rejected store rather than
|
|
2674
|
+
* losing it or retrying forever. The op is NOT materialized, so no other
|
|
2675
|
+
* replica ever sees it.
|
|
2676
|
+
*/
|
|
2677
|
+
sendOperationRejected(op, code, message, retriable) {
|
|
2678
|
+
const rejectedMsg = {
|
|
2679
|
+
type: "operation-rejected",
|
|
2680
|
+
messageId: generateUUIDv74(),
|
|
2681
|
+
operationId: op.id,
|
|
2682
|
+
collection: op.collection,
|
|
2683
|
+
recordId: op.recordId,
|
|
2684
|
+
code,
|
|
2685
|
+
message,
|
|
2686
|
+
retriable
|
|
2687
|
+
};
|
|
2688
|
+
this.sendToClient(rejectedMsg);
|
|
2689
|
+
}
|
|
2553
2690
|
setSerializerWireFormat(format) {
|
|
2554
2691
|
if (typeof this.serializer.setWireFormat === "function") {
|
|
2555
2692
|
this.serializer.setWireFormat(format);
|
|
@@ -2570,7 +2707,7 @@ function selectWireFormat(supportedWireFormats) {
|
|
|
2570
2707
|
}
|
|
2571
2708
|
|
|
2572
2709
|
// src/server/kora-sync-server.ts
|
|
2573
|
-
import { SyncError as SyncError4, generateUUIDv7 as
|
|
2710
|
+
import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv77, isBlobRef } from "@korajs/core";
|
|
2574
2711
|
import { SimpleEventEmitter } from "@korajs/core/internal";
|
|
2575
2712
|
import { JsonMessageSerializer as JsonMessageSerializer2 } from "@korajs/sync";
|
|
2576
2713
|
|
|
@@ -2787,6 +2924,115 @@ var ServerMetricsCollector = class {
|
|
|
2787
2924
|
}
|
|
2788
2925
|
};
|
|
2789
2926
|
|
|
2927
|
+
// src/richtext/blob-chunk-relay.ts
|
|
2928
|
+
import {
|
|
2929
|
+
encodeBlobChunkBytes
|
|
2930
|
+
} from "@korajs/sync";
|
|
2931
|
+
var BlobChunkRelay = class {
|
|
2932
|
+
clients = /* @__PURE__ */ new Map();
|
|
2933
|
+
/** requestId -> the session that originated the request (to route the answer back). */
|
|
2934
|
+
pending = /* @__PURE__ */ new Map();
|
|
2935
|
+
resolveBlobChunk;
|
|
2936
|
+
constructor(resolveBlobChunk) {
|
|
2937
|
+
this.resolveBlobChunk = resolveBlobChunk ?? null;
|
|
2938
|
+
}
|
|
2939
|
+
addClient(sessionId, transport) {
|
|
2940
|
+
this.clients.set(sessionId, { sessionId, transport });
|
|
2941
|
+
}
|
|
2942
|
+
removeClient(sessionId) {
|
|
2943
|
+
this.clients.delete(sessionId);
|
|
2944
|
+
for (const [requestId, originSessionId] of this.pending) {
|
|
2945
|
+
if (originSessionId === sessionId) {
|
|
2946
|
+
this.pending.delete(requestId);
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
getClientCount() {
|
|
2951
|
+
return this.clients.size;
|
|
2952
|
+
}
|
|
2953
|
+
getPendingCount() {
|
|
2954
|
+
return this.pending.size;
|
|
2955
|
+
}
|
|
2956
|
+
clear() {
|
|
2957
|
+
this.clients.clear();
|
|
2958
|
+
this.pending.clear();
|
|
2959
|
+
}
|
|
2960
|
+
/**
|
|
2961
|
+
* Handle an inbound chunk request from a session. Tries the server's own
|
|
2962
|
+
* store first (if configured), otherwise forwards the request to peer
|
|
2963
|
+
* sessions and remembers who to route the answer back to.
|
|
2964
|
+
*/
|
|
2965
|
+
handleRequest(sourceSessionId, message) {
|
|
2966
|
+
if (!this.clients.has(sourceSessionId)) {
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
if (this.resolveBlobChunk) {
|
|
2970
|
+
void this.resolveBlobChunk(message.hash).then(
|
|
2971
|
+
(bytes) => {
|
|
2972
|
+
if (bytes !== null) {
|
|
2973
|
+
this.sendResponseTo(sourceSessionId, message.requestId, encodeBlobChunkBytes(bytes));
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
2977
|
+
},
|
|
2978
|
+
() => {
|
|
2979
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
2980
|
+
}
|
|
2981
|
+
);
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
2985
|
+
}
|
|
2986
|
+
/**
|
|
2987
|
+
* Handle an inbound chunk response from a peer. Routes it back to the session
|
|
2988
|
+
* that originally requested it. A "not held" answer (bytes === null) is
|
|
2989
|
+
* ignored so a peer without the chunk does not preempt one that has it; the
|
|
2990
|
+
* requester's own per-request timeout bounds the wait.
|
|
2991
|
+
*/
|
|
2992
|
+
handleResponse(sourceSessionId, message) {
|
|
2993
|
+
if (!this.clients.has(sourceSessionId)) {
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
if (message.bytes === null) {
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
const originSessionId = this.pending.get(message.requestId);
|
|
3000
|
+
if (!originSessionId) {
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
this.pending.delete(message.requestId);
|
|
3004
|
+
this.sendResponseTo(originSessionId, message.requestId, message.bytes);
|
|
3005
|
+
}
|
|
3006
|
+
forwardRequestToPeers(sourceSessionId, message) {
|
|
3007
|
+
this.pending.set(message.requestId, sourceSessionId);
|
|
3008
|
+
for (const [, client] of this.clients) {
|
|
3009
|
+
if (client.sessionId === sourceSessionId) {
|
|
3010
|
+
continue;
|
|
3011
|
+
}
|
|
3012
|
+
if (!client.transport.isConnected()) {
|
|
3013
|
+
continue;
|
|
3014
|
+
}
|
|
3015
|
+
client.transport.send(message);
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
sendResponseTo(sessionId, requestId, bytes) {
|
|
3019
|
+
const client = this.clients.get(sessionId);
|
|
3020
|
+
if (!client || !client.transport.isConnected()) {
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
const response = {
|
|
3024
|
+
type: "blob-chunk-response",
|
|
3025
|
+
messageId: generateResponseId(requestId),
|
|
3026
|
+
requestId,
|
|
3027
|
+
bytes
|
|
3028
|
+
};
|
|
3029
|
+
client.transport.send(response);
|
|
3030
|
+
}
|
|
3031
|
+
};
|
|
3032
|
+
function generateResponseId(requestId) {
|
|
3033
|
+
return `blob-resp-${requestId}`;
|
|
3034
|
+
}
|
|
3035
|
+
|
|
2790
3036
|
// src/richtext/yjs-doc-relay.ts
|
|
2791
3037
|
var YjsDocRelay = class {
|
|
2792
3038
|
clients = /* @__PURE__ */ new Map();
|
|
@@ -2821,6 +3067,169 @@ var YjsDocRelay = class {
|
|
|
2821
3067
|
}
|
|
2822
3068
|
};
|
|
2823
3069
|
|
|
3070
|
+
// src/server/route-context.ts
|
|
3071
|
+
import { HybridLogicalClock as HybridLogicalClock2, createOperation as createOperation2, generateUUIDv7 as generateUUIDv76 } from "@korajs/core";
|
|
3072
|
+
function recordMatchesScope(collection, record, scope) {
|
|
3073
|
+
const collectionScope = scope[collection];
|
|
3074
|
+
if (!collectionScope) {
|
|
3075
|
+
return false;
|
|
3076
|
+
}
|
|
3077
|
+
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
3078
|
+
if (record[field] !== expected) {
|
|
3079
|
+
return false;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
return true;
|
|
3083
|
+
}
|
|
3084
|
+
function pickFields(record, keys) {
|
|
3085
|
+
const picked = {};
|
|
3086
|
+
for (const key of keys) {
|
|
3087
|
+
if (key in record) {
|
|
3088
|
+
picked[key] = record[key];
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
return picked;
|
|
3092
|
+
}
|
|
3093
|
+
async function buildRouteOperation(store, mutation) {
|
|
3094
|
+
const nodeId = store.getNodeId();
|
|
3095
|
+
const clock = new HybridLogicalClock2(nodeId);
|
|
3096
|
+
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
3097
|
+
const sequenceNumber = nextServerSequenceNumber(store);
|
|
3098
|
+
if (mutation.type === "insert") {
|
|
3099
|
+
return createOperation2(
|
|
3100
|
+
{
|
|
3101
|
+
nodeId,
|
|
3102
|
+
type: "insert",
|
|
3103
|
+
collection: mutation.collection,
|
|
3104
|
+
recordId: mutation.recordId ?? generateUUIDv76(),
|
|
3105
|
+
data: mutation.data ?? {},
|
|
3106
|
+
previousData: null,
|
|
3107
|
+
sequenceNumber,
|
|
3108
|
+
causalDeps: [],
|
|
3109
|
+
schemaVersion
|
|
3110
|
+
},
|
|
3111
|
+
clock
|
|
3112
|
+
);
|
|
3113
|
+
}
|
|
3114
|
+
if (!mutation.recordId) {
|
|
3115
|
+
throw new RouteMutationError(
|
|
3116
|
+
"MISSING_RECORD_ID",
|
|
3117
|
+
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
3118
|
+
);
|
|
3119
|
+
}
|
|
3120
|
+
const current = await store.findRecord(mutation.collection, mutation.recordId);
|
|
3121
|
+
if (mutation.type === "update") {
|
|
3122
|
+
const data = mutation.data ?? {};
|
|
3123
|
+
return createOperation2(
|
|
3124
|
+
{
|
|
3125
|
+
nodeId,
|
|
3126
|
+
type: "update",
|
|
3127
|
+
collection: mutation.collection,
|
|
3128
|
+
recordId: mutation.recordId,
|
|
3129
|
+
data,
|
|
3130
|
+
previousData: current ? pickFields(current, Object.keys(data)) : {},
|
|
3131
|
+
sequenceNumber,
|
|
3132
|
+
causalDeps: [],
|
|
3133
|
+
schemaVersion
|
|
3134
|
+
},
|
|
3135
|
+
clock
|
|
3136
|
+
);
|
|
3137
|
+
}
|
|
3138
|
+
return createOperation2(
|
|
3139
|
+
{
|
|
3140
|
+
nodeId,
|
|
3141
|
+
type: "delete",
|
|
3142
|
+
collection: mutation.collection,
|
|
3143
|
+
recordId: mutation.recordId,
|
|
3144
|
+
data: null,
|
|
3145
|
+
previousData: current ? { ...current } : null,
|
|
3146
|
+
sequenceNumber,
|
|
3147
|
+
causalDeps: [],
|
|
3148
|
+
schemaVersion
|
|
3149
|
+
},
|
|
3150
|
+
clock
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
var RouteMutationError = class extends Error {
|
|
3154
|
+
constructor(code, message) {
|
|
3155
|
+
super(message);
|
|
3156
|
+
this.code = code;
|
|
3157
|
+
this.name = "RouteMutationError";
|
|
3158
|
+
}
|
|
3159
|
+
code;
|
|
3160
|
+
};
|
|
3161
|
+
function createRouteContext(server, store) {
|
|
3162
|
+
let mutationTail = Promise.resolve();
|
|
3163
|
+
function serialize(work) {
|
|
3164
|
+
const run = mutationTail.then(work, work);
|
|
3165
|
+
mutationTail = run.then(
|
|
3166
|
+
() => void 0,
|
|
3167
|
+
() => void 0
|
|
3168
|
+
);
|
|
3169
|
+
return run;
|
|
3170
|
+
}
|
|
3171
|
+
return {
|
|
3172
|
+
apply(mutation, options) {
|
|
3173
|
+
return serialize(async () => {
|
|
3174
|
+
let op;
|
|
3175
|
+
try {
|
|
3176
|
+
op = await buildRouteOperation(store, mutation);
|
|
3177
|
+
} catch (error) {
|
|
3178
|
+
if (error instanceof RouteMutationError) {
|
|
3179
|
+
return {
|
|
3180
|
+
ok: false,
|
|
3181
|
+
code: error.code,
|
|
3182
|
+
message: error.message,
|
|
3183
|
+
retriable: isRetriableRejection(error.code)
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
throw error;
|
|
3187
|
+
}
|
|
3188
|
+
if (options?.scope && !operationMatchesScopes(op, options.scope)) {
|
|
3189
|
+
return {
|
|
3190
|
+
ok: false,
|
|
3191
|
+
code: "SCOPE_VIOLATION",
|
|
3192
|
+
message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
|
|
3193
|
+
retriable: isRetriableRejection("SCOPE_VIOLATION")
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
3196
|
+
const result = await server.applyLocalOperation(op);
|
|
3197
|
+
if (result.result !== "applied") {
|
|
3198
|
+
const code = result.rejection?.code ?? "NOT_APPLIED";
|
|
3199
|
+
return {
|
|
3200
|
+
ok: false,
|
|
3201
|
+
code,
|
|
3202
|
+
message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`,
|
|
3203
|
+
// Prefer the pipeline's own classification when present; fall back
|
|
3204
|
+
// to classifying the code so this stays correct if new codes appear.
|
|
3205
|
+
retriable: result.rejection?.retriable ?? isRetriableRejection(code)
|
|
3206
|
+
};
|
|
3207
|
+
}
|
|
3208
|
+
const record = await store.findRecord(mutation.collection, op.recordId);
|
|
3209
|
+
return { ok: true, operation: op, record };
|
|
3210
|
+
});
|
|
3211
|
+
},
|
|
3212
|
+
async query(collection, options) {
|
|
3213
|
+
const { scope, ...queryOptions } = options ?? {};
|
|
3214
|
+
const records = await store.queryCollection(collection, queryOptions);
|
|
3215
|
+
if (!scope) {
|
|
3216
|
+
return records;
|
|
3217
|
+
}
|
|
3218
|
+
return records.filter((record) => recordMatchesScope(collection, record, scope));
|
|
3219
|
+
},
|
|
3220
|
+
async findById(collection, id, options) {
|
|
3221
|
+
const record = await store.findRecord(collection, id);
|
|
3222
|
+
if (!record) {
|
|
3223
|
+
return null;
|
|
3224
|
+
}
|
|
3225
|
+
if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
|
|
3226
|
+
return null;
|
|
3227
|
+
}
|
|
3228
|
+
return record;
|
|
3229
|
+
}
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
|
|
2824
3233
|
// src/server/kora-sync-server.ts
|
|
2825
3234
|
var DEFAULT_MAX_CONNECTIONS = 0;
|
|
2826
3235
|
var DEFAULT_BATCH_SIZE2 = 100;
|
|
@@ -2843,10 +3252,18 @@ var KoraSyncServer = class {
|
|
|
2843
3252
|
metrics;
|
|
2844
3253
|
awarenessRelay = new AwarenessRelay();
|
|
2845
3254
|
yjsDocRelay = new YjsDocRelay();
|
|
3255
|
+
blobChunkRelay;
|
|
3256
|
+
persistBlobChunk;
|
|
3257
|
+
maxOperationBytes;
|
|
3258
|
+
maxOpsPerMinute;
|
|
3259
|
+
validateOperation;
|
|
3260
|
+
koraContext;
|
|
2846
3261
|
sessions = /* @__PURE__ */ new Map();
|
|
2847
3262
|
httpClients = /* @__PURE__ */ new Map();
|
|
2848
3263
|
httpSessionToClient = /* @__PURE__ */ new Map();
|
|
2849
|
-
|
|
3264
|
+
// Informational value reported by getStatus(). Keep in sync with the
|
|
3265
|
+
// @korajs/server package version on release; it is not used for protocol negotiation.
|
|
3266
|
+
serverVersion = "1.0.0-beta.0";
|
|
2850
3267
|
wsServer = null;
|
|
2851
3268
|
running = false;
|
|
2852
3269
|
constructor(config) {
|
|
@@ -2867,10 +3284,24 @@ var KoraSyncServer = class {
|
|
|
2867
3284
|
this.logger = config.logger ?? createDefaultLogger();
|
|
2868
3285
|
this.metrics = config.metricsCollector ?? new ServerMetricsCollector();
|
|
2869
3286
|
this.metrics.setSchemaVersion(this.schemaVersion);
|
|
3287
|
+
this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
|
|
3288
|
+
this.persistBlobChunk = config.persistBlobChunk ?? null;
|
|
3289
|
+
this.maxOperationBytes = config.maxOperationBytes;
|
|
3290
|
+
this.maxOpsPerMinute = config.maxOpsPerMinute;
|
|
3291
|
+
this.validateOperation = config.validateOperation;
|
|
3292
|
+
this.koraContext = createRouteContext(this, this.store);
|
|
2870
3293
|
if (!this.emitter) {
|
|
2871
3294
|
this.emitter = new SimpleEventEmitter();
|
|
2872
3295
|
}
|
|
2873
3296
|
}
|
|
3297
|
+
/**
|
|
3298
|
+
* The trusted, scoped data-plane context (`apply` / `query` / `findById`).
|
|
3299
|
+
* Shared with the production server so HTTP routes, the operation validator,
|
|
3300
|
+
* and `server.kora` are all the same object over the same pipeline.
|
|
3301
|
+
*/
|
|
3302
|
+
getKoraContext() {
|
|
3303
|
+
return this.koraContext;
|
|
3304
|
+
}
|
|
2874
3305
|
/**
|
|
2875
3306
|
* Subscribe to session-level events for metrics collection and logging.
|
|
2876
3307
|
* Called when a new session is created.
|
|
@@ -2990,6 +3421,7 @@ var KoraSyncServer = class {
|
|
|
2990
3421
|
});
|
|
2991
3422
|
this.awarenessRelay.clear();
|
|
2992
3423
|
this.yjsDocRelay.clear();
|
|
3424
|
+
this.blobChunkRelay.clear();
|
|
2993
3425
|
for (const session of this.sessions.values()) {
|
|
2994
3426
|
session.close("server shutting down");
|
|
2995
3427
|
}
|
|
@@ -3051,7 +3483,7 @@ var KoraSyncServer = class {
|
|
|
3051
3483
|
if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
|
|
3052
3484
|
transport.send({
|
|
3053
3485
|
type: "error",
|
|
3054
|
-
messageId:
|
|
3486
|
+
messageId: generateUUIDv77(),
|
|
3055
3487
|
code: "MAX_CONNECTIONS",
|
|
3056
3488
|
message: `Server has reached maximum connections (${this.maxConnections})`,
|
|
3057
3489
|
retriable: true
|
|
@@ -3069,7 +3501,7 @@ var KoraSyncServer = class {
|
|
|
3069
3501
|
max: this.maxConnections
|
|
3070
3502
|
});
|
|
3071
3503
|
}
|
|
3072
|
-
const sessionId =
|
|
3504
|
+
const sessionId = generateUUIDv77();
|
|
3073
3505
|
this.metrics.recordConnection(sessionId);
|
|
3074
3506
|
const sessionEmitter = new SimpleEventEmitter();
|
|
3075
3507
|
sessionEmitter.on("sync:connected", (event) => {
|
|
@@ -3134,12 +3566,25 @@ var KoraSyncServer = class {
|
|
|
3134
3566
|
onYjsDocUpdate: (sourceSessionId, message) => {
|
|
3135
3567
|
this.handleYjsDocRelay(sourceSessionId, message);
|
|
3136
3568
|
},
|
|
3569
|
+
onBlobChunkRequest: (sourceSessionId, message) => {
|
|
3570
|
+
this.blobChunkRelay.handleRequest(sourceSessionId, message);
|
|
3571
|
+
},
|
|
3572
|
+
onBlobChunkResponse: (sourceSessionId, message) => {
|
|
3573
|
+
this.blobChunkRelay.handleResponse(sourceSessionId, message);
|
|
3574
|
+
},
|
|
3575
|
+
...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
|
|
3576
|
+
// Only forward when configured, so an unset server value leaves the
|
|
3577
|
+
// session on its own documented default rather than `undefined`.
|
|
3578
|
+
...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
|
|
3579
|
+
...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
|
|
3580
|
+
...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
|
|
3137
3581
|
onClose: (sid) => {
|
|
3138
3582
|
this.handleSessionClose(sid);
|
|
3139
3583
|
}
|
|
3140
3584
|
});
|
|
3141
3585
|
this.sessions.set(sessionId, session);
|
|
3142
3586
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
3587
|
+
this.blobChunkRelay.addClient(sessionId, transport);
|
|
3143
3588
|
session.start();
|
|
3144
3589
|
this.logger.log({
|
|
3145
3590
|
timestamp: Date.now(),
|
|
@@ -3172,6 +3617,61 @@ var KoraSyncServer = class {
|
|
|
3172
3617
|
errorCount: snapshot.errorCount
|
|
3173
3618
|
};
|
|
3174
3619
|
}
|
|
3620
|
+
/**
|
|
3621
|
+
* Apply a server-originated operation (for example one created by a custom
|
|
3622
|
+
* HTTP route) through the same validated pipeline that incoming client
|
|
3623
|
+
* operations use — Tier 2 constraints, referential integrity, and cascade
|
|
3624
|
+
* side effects — then relay every applied operation to connected clients.
|
|
3625
|
+
*
|
|
3626
|
+
* Because the operation did not come from a client session, it is relayed to
|
|
3627
|
+
* ALL sessions (there is no source session to exclude). Each session still
|
|
3628
|
+
* applies its own per-scope visibility filter in `relayOperations`, so a
|
|
3629
|
+
* client only receives the operation if it falls within that client's scope.
|
|
3630
|
+
*
|
|
3631
|
+
* @param op - A fully-formed, server-originated operation to apply
|
|
3632
|
+
* @returns The apply result, including any server-generated side-effect ops
|
|
3633
|
+
*/
|
|
3634
|
+
async applyLocalOperation(op) {
|
|
3635
|
+
const result = await applyServerOperation(this.store, op);
|
|
3636
|
+
if (result.result === "applied" && result.appliedOperations.length > 0) {
|
|
3637
|
+
for (const session of this.sessions.values()) {
|
|
3638
|
+
session.relayOperations(result.appliedOperations);
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
return result;
|
|
3642
|
+
}
|
|
3643
|
+
/**
|
|
3644
|
+
* Collect every blob reference still reachable from live records on the server.
|
|
3645
|
+
*
|
|
3646
|
+
* This is the live set for garbage-collecting the server's central blob store:
|
|
3647
|
+
* pass the result to `collectBlobGarbage(blobStore, liveRefs)` from
|
|
3648
|
+
* `@korajs/store` to reclaim bytes no record references any more. Only
|
|
3649
|
+
* collections that declare a `blob` field are scanned.
|
|
3650
|
+
*
|
|
3651
|
+
* @returns Every live blob reference across all collections
|
|
3652
|
+
*/
|
|
3653
|
+
async getLiveBlobRefs() {
|
|
3654
|
+
const schema = this.store.getSchema();
|
|
3655
|
+
if (!schema) {
|
|
3656
|
+
return [];
|
|
3657
|
+
}
|
|
3658
|
+
const refs = [];
|
|
3659
|
+
for (const [name, collection] of Object.entries(schema.collections)) {
|
|
3660
|
+
const hasBlobField = Object.values(collection.fields).some((field) => field.kind === "blob");
|
|
3661
|
+
if (!hasBlobField) {
|
|
3662
|
+
continue;
|
|
3663
|
+
}
|
|
3664
|
+
const records = await this.store.materializeCollection(name);
|
|
3665
|
+
for (const record of records) {
|
|
3666
|
+
for (const value of Object.values(record)) {
|
|
3667
|
+
if (isBlobRef(value)) {
|
|
3668
|
+
refs.push(value);
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
return refs;
|
|
3674
|
+
}
|
|
3175
3675
|
/**
|
|
3176
3676
|
* Get the number of currently connected clients.
|
|
3177
3677
|
*/
|
|
@@ -3205,6 +3705,7 @@ var KoraSyncServer = class {
|
|
|
3205
3705
|
this.metrics.recordDisconnection(sessionId);
|
|
3206
3706
|
this.awarenessRelay.removeClient(sessionId);
|
|
3207
3707
|
this.yjsDocRelay.removeClient(sessionId);
|
|
3708
|
+
this.blobChunkRelay.removeClient(sessionId);
|
|
3208
3709
|
this.sessions.delete(sessionId);
|
|
3209
3710
|
const clientId = this.httpSessionToClient.get(sessionId);
|
|
3210
3711
|
if (clientId) {
|
|
@@ -3257,13 +3758,6 @@ function normalizeHttpBody(body, contentType) {
|
|
|
3257
3758
|
return body;
|
|
3258
3759
|
}
|
|
3259
3760
|
|
|
3260
|
-
// src/auth/no-auth.ts
|
|
3261
|
-
var NoAuthProvider = class {
|
|
3262
|
-
async authenticate(_token) {
|
|
3263
|
-
return { userId: "anonymous" };
|
|
3264
|
-
}
|
|
3265
|
-
};
|
|
3266
|
-
|
|
3267
3761
|
// src/auth/token-auth.ts
|
|
3268
3762
|
var TokenAuthProvider = class {
|
|
3269
3763
|
validate;
|
|
@@ -3368,6 +3862,7 @@ function createProductionServer(config) {
|
|
|
3368
3862
|
enableDashboard: true,
|
|
3369
3863
|
...config.syncOptions
|
|
3370
3864
|
});
|
|
3865
|
+
const routeContext = syncServer.getKoraContext();
|
|
3371
3866
|
let httpServer = null;
|
|
3372
3867
|
function getOperationalToken(kind) {
|
|
3373
3868
|
const auth = config.operationalAuth;
|
|
@@ -3454,6 +3949,10 @@ function createProductionServer(config) {
|
|
|
3454
3949
|
const chunks = [];
|
|
3455
3950
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
3456
3951
|
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
3952
|
+
if (!req.readableFlowing) {
|
|
3953
|
+
req.resume();
|
|
3954
|
+
}
|
|
3955
|
+
req.on("error", () => resolve(Buffer.concat(chunks)));
|
|
3457
3956
|
});
|
|
3458
3957
|
}
|
|
3459
3958
|
async function readJsonBody(req) {
|
|
@@ -3503,6 +4002,10 @@ function createProductionServer(config) {
|
|
|
3503
4002
|
res.end(JSON.stringify(result.body ?? null));
|
|
3504
4003
|
}
|
|
3505
4004
|
return {
|
|
4005
|
+
kora: routeContext,
|
|
4006
|
+
getLiveBlobRefs() {
|
|
4007
|
+
return syncServer.getLiveBlobRefs();
|
|
4008
|
+
},
|
|
3506
4009
|
async start() {
|
|
3507
4010
|
const { createServer } = await import("http");
|
|
3508
4011
|
const { createReadStream, existsSync, statSync } = await import("fs");
|
|
@@ -3510,6 +4013,19 @@ function createProductionServer(config) {
|
|
|
3510
4013
|
const { WebSocketServer } = await import("ws");
|
|
3511
4014
|
const distDir = resolve(staticDir);
|
|
3512
4015
|
httpServer = createServer(async (req, res) => {
|
|
4016
|
+
try {
|
|
4017
|
+
await handleRequest(req, res);
|
|
4018
|
+
} catch (error) {
|
|
4019
|
+
console.error("[kora] Unhandled error in HTTP request handler:", error);
|
|
4020
|
+
if (!res.headersSent) {
|
|
4021
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
4022
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
4023
|
+
} else if (!res.writableEnded) {
|
|
4024
|
+
res.end();
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
});
|
|
4028
|
+
async function handleRequest(req, res) {
|
|
3513
4029
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
3514
4030
|
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
|
3515
4031
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
@@ -3637,7 +4153,8 @@ data: ${JSON.stringify(s)}
|
|
|
3637
4153
|
body: await readJsonBody(req),
|
|
3638
4154
|
headers: req.headers,
|
|
3639
4155
|
query: getQuery(url),
|
|
3640
|
-
ip: getClientIp(req)
|
|
4156
|
+
ip: getClientIp(req),
|
|
4157
|
+
kora: routeContext
|
|
3641
4158
|
});
|
|
3642
4159
|
writeJsonResponse(res, result);
|
|
3643
4160
|
return;
|
|
@@ -3671,7 +4188,7 @@ data: ${JSON.stringify(s)}
|
|
|
3671
4188
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
3672
4189
|
res.writeHead(200, { "Content-Type": contentType });
|
|
3673
4190
|
createReadStream(filePath).pipe(res);
|
|
3674
|
-
}
|
|
4191
|
+
}
|
|
3675
4192
|
const wss = new WebSocketServer({ noServer: true });
|
|
3676
4193
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
3677
4194
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
@@ -3787,6 +4304,7 @@ export {
|
|
|
3787
4304
|
MixedAuthProvider,
|
|
3788
4305
|
NoAuthProvider,
|
|
3789
4306
|
PostgresServerStore,
|
|
4307
|
+
RETRIABLE_REJECTION_CODES,
|
|
3790
4308
|
SqliteServerStore,
|
|
3791
4309
|
TokenAuthProvider,
|
|
3792
4310
|
WsServerTransport,
|
|
@@ -3797,6 +4315,7 @@ export {
|
|
|
3797
4315
|
createPrettyLogger,
|
|
3798
4316
|
createProductionServer,
|
|
3799
4317
|
createSilentLogger,
|
|
3800
|
-
createSqliteServerStore
|
|
4318
|
+
createSqliteServerStore,
|
|
4319
|
+
isRetriableRejection
|
|
3801
4320
|
};
|
|
3802
4321
|
//# sourceMappingURL=index.js.map
|