@rebasepro/server 0.9.1-canary.f2f61da → 0.9.1-canary.ff338b5
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/cron/cron-store.d.ts +18 -0
- package/dist/env.d.ts +3 -0
- package/dist/index.es.js +621 -99
- package/dist/index.es.js.map +1 -1
- package/dist/init.d.ts +21 -0
- package/dist/storage/LocalStorageController.d.ts +19 -2
- package/dist/storage/routes.d.ts +8 -1
- package/dist/storage/tus-handler.d.ts +21 -1
- package/dist/storage/types.d.ts +33 -0
- package/package.json +7 -6
package/dist/index.es.js
CHANGED
|
@@ -3305,7 +3305,7 @@ var BackendCollectionRegistry = class extends CollectionRegistry {
|
|
|
3305
3305
|
//#endregion
|
|
3306
3306
|
//#region src/collections/loader.ts
|
|
3307
3307
|
function isCollectionFile(file) {
|
|
3308
|
-
return (file.endsWith(".ts") || file.endsWith(".js")) && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js";
|
|
3308
|
+
return (file.endsWith(".ts") || file.endsWith(".js")) && !file.startsWith(".") && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js";
|
|
3309
3309
|
}
|
|
3310
3310
|
async function importModule(filePath) {
|
|
3311
3311
|
return await import(pathToFileURL(filePath).href);
|
|
@@ -11623,11 +11623,21 @@ var LocalStorageController = class {
|
|
|
11623
11623
|
}
|
|
11624
11624
|
}
|
|
11625
11625
|
/**
|
|
11626
|
-
* Get the full filesystem path for a storage path
|
|
11627
|
-
*
|
|
11626
|
+
* Get the full filesystem path for a storage path, with a traversal guard
|
|
11627
|
+
* that keeps the result inside the bucket directory.
|
|
11628
|
+
*
|
|
11629
|
+
* Defaults the bucket the way `putObject` does.
|
|
11630
|
+
*
|
|
11631
|
+
* `putObject` has always written into `default` when given no bucket, while
|
|
11632
|
+
* the read side resolved a bare key against the storage root — where
|
|
11633
|
+
* nothing is. The two disagreed silently: `getObject` returned null (reads
|
|
11634
|
+
* as "file missing"), `deleteObject` deleted nothing (404s are swallowed by
|
|
11635
|
+
* design), and `listObjects` returned an empty page. So the obvious
|
|
11636
|
+
* `putObject({ key })` → `getObject(key)` did not round-trip and nothing
|
|
11637
|
+
* said why. One default, applied everywhere, removes the whole class.
|
|
11628
11638
|
*/
|
|
11629
11639
|
getFullPath(storagePath, bucket) {
|
|
11630
|
-
const bucketPath =
|
|
11640
|
+
const bucketPath = path$1.join(this.basePath, bucket ?? "default");
|
|
11631
11641
|
const resolved = path$1.resolve(path$1.join(bucketPath, storagePath));
|
|
11632
11642
|
if (!resolved.startsWith(bucketPath + path$1.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
|
|
11633
11643
|
return resolved;
|
|
@@ -11788,8 +11798,10 @@ var LocalStorageController = class {
|
|
|
11788
11798
|
let count = 0;
|
|
11789
11799
|
const maxResults = options?.maxResults ?? 1e3;
|
|
11790
11800
|
const startIndex = options?.pageToken ? parseInt(options.pageToken, 10) : 0;
|
|
11801
|
+
let scanned = startIndex;
|
|
11791
11802
|
for (let i = startIndex; i < entries.length && count < maxResults; i++) {
|
|
11792
11803
|
const entry = entries[i];
|
|
11804
|
+
scanned = i + 1;
|
|
11793
11805
|
if (entry.name.endsWith(".metadata.json")) continue;
|
|
11794
11806
|
const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
11795
11807
|
const bucket = options?.bucket ?? "default";
|
|
@@ -11808,7 +11820,7 @@ var LocalStorageController = class {
|
|
|
11808
11820
|
return {
|
|
11809
11821
|
items,
|
|
11810
11822
|
prefixes,
|
|
11811
|
-
nextPageToken:
|
|
11823
|
+
nextPageToken: scanned < entries.length ? String(scanned) : void 0
|
|
11812
11824
|
};
|
|
11813
11825
|
} catch (error) {
|
|
11814
11826
|
const code = error?.code;
|
|
@@ -13759,12 +13771,14 @@ var UPLOAD_EXPIRY_MS = 1440 * 60 * 1e3;
|
|
|
13759
13771
|
var TusHandler = class {
|
|
13760
13772
|
storageController;
|
|
13761
13773
|
storageRegistry;
|
|
13774
|
+
authorizeUpload;
|
|
13762
13775
|
uploads = /* @__PURE__ */ new Map();
|
|
13763
13776
|
tusDir;
|
|
13764
13777
|
cleanupTimer;
|
|
13765
|
-
constructor(storageBaseDir, storageController, storageRegistry) {
|
|
13778
|
+
constructor(storageBaseDir, storageController, storageRegistry, authorizeUpload) {
|
|
13766
13779
|
this.storageController = storageController;
|
|
13767
13780
|
this.storageRegistry = storageRegistry;
|
|
13781
|
+
this.authorizeUpload = authorizeUpload;
|
|
13768
13782
|
this.tusDir = join(storageBaseDir, ".tus-uploads");
|
|
13769
13783
|
}
|
|
13770
13784
|
/** Ensure the temp directory exists. */
|
|
@@ -13828,6 +13842,10 @@ var TusHandler = class {
|
|
|
13828
13842
|
if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
|
|
13829
13843
|
if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
|
|
13830
13844
|
const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
|
|
13845
|
+
if (this.authorizeUpload) {
|
|
13846
|
+
const key = metadata.key || metadata.filename || "";
|
|
13847
|
+
await this.authorizeUpload(c, key, metadata.bucket || "default");
|
|
13848
|
+
}
|
|
13831
13849
|
const id = randomUUID$1();
|
|
13832
13850
|
const filePath = join(this.tusDir, id);
|
|
13833
13851
|
await writeFile(filePath, Buffer.alloc(0));
|
|
@@ -14035,7 +14053,34 @@ function buildAdapterAuthMiddleware(adapter, requireAuth, publicRead) {
|
|
|
14035
14053
|
function createStorageRoutes(config) {
|
|
14036
14054
|
const router = new Hono();
|
|
14037
14055
|
router.onError(errorHandler);
|
|
14038
|
-
const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter } = config;
|
|
14056
|
+
const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter, authorize } = config;
|
|
14057
|
+
/**
|
|
14058
|
+
* Run the per-object authorization hook, if one is configured.
|
|
14059
|
+
*
|
|
14060
|
+
* Denials are 403 rather than 404: the route already established that the
|
|
14061
|
+
* caller is authenticated, so hiding existence buys nothing, and a
|
|
14062
|
+
* distinguishable status is what makes a misconfigured policy debuggable.
|
|
14063
|
+
* A hook that throws denies too — an ownership lookup that fails must not
|
|
14064
|
+
* fall open.
|
|
14065
|
+
*/
|
|
14066
|
+
const checkAuthorized = async (c, operation, key, bucket, storageId) => {
|
|
14067
|
+
if (!authorize) return;
|
|
14068
|
+
const user = c.get("user") ?? null;
|
|
14069
|
+
if (user?.userId === "download-token" || user?.userId === "public") return;
|
|
14070
|
+
let allowed;
|
|
14071
|
+
try {
|
|
14072
|
+
allowed = await authorize({
|
|
14073
|
+
key,
|
|
14074
|
+
bucket,
|
|
14075
|
+
operation,
|
|
14076
|
+
user,
|
|
14077
|
+
storageId: storageId ?? void 0
|
|
14078
|
+
});
|
|
14079
|
+
} catch {
|
|
14080
|
+
allowed = false;
|
|
14081
|
+
}
|
|
14082
|
+
if (!allowed) throw ApiError.forbidden("Not authorized for this object");
|
|
14083
|
+
};
|
|
14039
14084
|
/**
|
|
14040
14085
|
* Resolve the storage controller for a request.
|
|
14041
14086
|
* Looks up by `storageId` in the registry, falls back to the single
|
|
@@ -14085,6 +14130,7 @@ function createStorageRoutes(config) {
|
|
|
14085
14130
|
const finalKey = sanitizeStorageKey(key || uploadedFile.name || "unnamed");
|
|
14086
14131
|
const metadata = {};
|
|
14087
14132
|
for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
|
|
14133
|
+
await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
|
|
14088
14134
|
const result = await resolveController(storageId).putObject({
|
|
14089
14135
|
file: uploadedFile,
|
|
14090
14136
|
key: finalKey,
|
|
@@ -14105,7 +14151,12 @@ function createStorageRoutes(config) {
|
|
|
14105
14151
|
const rawPath = extractWildcardPath(c);
|
|
14106
14152
|
if (!rawPath) throw ApiError.notFound("File not found");
|
|
14107
14153
|
const filePath = decodeURIComponent(rawPath);
|
|
14108
|
-
const
|
|
14154
|
+
const storageId = c.req.query("storageId");
|
|
14155
|
+
const resolved = resolveController(storageId);
|
|
14156
|
+
{
|
|
14157
|
+
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
14158
|
+
await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
|
|
14159
|
+
}
|
|
14109
14160
|
const transformOpts = parseTransformOptions(c.req.query());
|
|
14110
14161
|
if (resolved.getType() === "local") {
|
|
14111
14162
|
const localController = resolved;
|
|
@@ -14168,8 +14219,10 @@ function createStorageRoutes(config) {
|
|
|
14168
14219
|
fileNotFound: true
|
|
14169
14220
|
}, 404);
|
|
14170
14221
|
const filePath = decodeURIComponent(rawPath);
|
|
14171
|
-
const
|
|
14222
|
+
const storageId = c.req.query("storageId");
|
|
14223
|
+
const resolved = resolveController(storageId);
|
|
14172
14224
|
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
14225
|
+
await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
|
|
14173
14226
|
const downloadConfig = await resolved.getSignedUrl(resolvedPath, bucket);
|
|
14174
14227
|
if (downloadConfig.fileNotFound) throw ApiError.notFound("File not found");
|
|
14175
14228
|
if (downloadConfig.metadata) {
|
|
@@ -14195,8 +14248,10 @@ function createStorageRoutes(config) {
|
|
|
14195
14248
|
message: "No file to delete"
|
|
14196
14249
|
});
|
|
14197
14250
|
const filePath = decodeURIComponent(rawPath);
|
|
14198
|
-
const
|
|
14251
|
+
const storageId = c.req.query("storageId");
|
|
14252
|
+
const resolved = resolveController(storageId);
|
|
14199
14253
|
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
14254
|
+
await checkAuthorized(c, "delete", resolvedPath, bucket, storageId);
|
|
14200
14255
|
await resolved.deleteObject(resolvedPath, bucket);
|
|
14201
14256
|
return c.json({
|
|
14202
14257
|
success: true,
|
|
@@ -14211,7 +14266,9 @@ function createStorageRoutes(config) {
|
|
|
14211
14266
|
const bucket = c.req.query("bucket");
|
|
14212
14267
|
const maxResults = c.req.query("maxResults");
|
|
14213
14268
|
const pageToken = c.req.query("pageToken");
|
|
14214
|
-
const
|
|
14269
|
+
const storageId = c.req.query("storageId");
|
|
14270
|
+
const resolved = resolveController(storageId);
|
|
14271
|
+
await checkAuthorized(c, "list", storagePrefix, bucket ?? "default", storageId);
|
|
14215
14272
|
const result = await resolved.listObjects(storagePrefix, {
|
|
14216
14273
|
bucket: bucket ?? (resolved.getType() === "local" ? "default" : void 0),
|
|
14217
14274
|
maxResults: maxResults ? parseInt(maxResults, 10) : void 0,
|
|
@@ -14234,6 +14291,7 @@ function createStorageRoutes(config) {
|
|
|
14234
14291
|
const resolved = resolveController(storageId);
|
|
14235
14292
|
const { bucket, resolvedPath } = parseBucketAndPath(folderPath);
|
|
14236
14293
|
if (!resolvedPath || resolvedPath.trim() === "") throw ApiError.badRequest("Invalid folder path");
|
|
14294
|
+
await checkAuthorized(c, "write", resolvedPath, bucket, storageId);
|
|
14237
14295
|
if (resolved.getType() === "local") {
|
|
14238
14296
|
const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
|
|
14239
14297
|
fs$1.mkdirSync(absolutePath, { recursive: true });
|
|
@@ -14251,7 +14309,9 @@ function createStorageRoutes(config) {
|
|
|
14251
14309
|
}, 201);
|
|
14252
14310
|
});
|
|
14253
14311
|
const defaultCtrl = getDefaultController();
|
|
14254
|
-
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry)
|
|
14312
|
+
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
|
|
14313
|
+
await checkAuthorized(c, "write", sanitizeStorageKey(key), bucket, c.req.query("storageId"));
|
|
14314
|
+
} : void 0);
|
|
14255
14315
|
tusHandler.startCleanup();
|
|
14256
14316
|
router.options("/tus", (_c) => tusHandler.options());
|
|
14257
14317
|
router.post("/tus", writeAuthMiddleware, async (c) => tusHandler.create(c));
|
|
@@ -14392,7 +14452,7 @@ async function initializeStorage(storageConfig, isProduction) {
|
|
|
14392
14452
|
const toController = async (entry, label) => {
|
|
14393
14453
|
if (typeof entry.putObject === "function") return entry;
|
|
14394
14454
|
const conf = entry;
|
|
14395
|
-
if (isProduction && conf.type === "local")
|
|
14455
|
+
if (isProduction && conf.type === "local" && !process.env.FORCE_LOCAL_STORAGE) throw new Error(`Storage backend "${label}" is set to "local" in production. Local storage is the container filesystem, so uploaded files are destroyed on the next restart or redeploy. Configure S3-compatible storage (STORAGE_TYPE=s3) or GCS (STORAGE_TYPE=gcs), or pass a custom StorageController. If this deployment really does have a durable volume mounted at the storage path, set FORCE_LOCAL_STORAGE=true to proceed.`);
|
|
14396
14456
|
return await createStorageController(conf);
|
|
14397
14457
|
};
|
|
14398
14458
|
if (typeof storageConfig === "object" && ("type" in storageConfig || typeof storageConfig.putObject === "function")) controllers[DEFAULT_STORAGE_ID] = await toController(storageConfig, DEFAULT_STORAGE_ID);
|
|
@@ -15933,12 +15993,28 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
|
|
|
15933
15993
|
function extractMessageError(message) {
|
|
15934
15994
|
const payload = message.payload;
|
|
15935
15995
|
const errPayload = payload?.error;
|
|
15996
|
+
const errorMessage = typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error";
|
|
15997
|
+
const errorCode = typeof errPayload === "object" ? errPayload.code : payload?.code;
|
|
15936
15998
|
return {
|
|
15937
|
-
errorMessage: typeof
|
|
15938
|
-
errorCode
|
|
15999
|
+
errorMessage: typeof errorMessage === "string" ? errorMessage : errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage),
|
|
16000
|
+
errorCode
|
|
15939
16001
|
};
|
|
15940
16002
|
}
|
|
15941
16003
|
/**
|
|
16004
|
+
* Broadcast and presence frames.
|
|
16005
|
+
*
|
|
16006
|
+
* Fire-and-forget (the server sends no response envelope), and exempt from the
|
|
16007
|
+
* client-side auth gate — a public channel is usable without an account.
|
|
16008
|
+
*/
|
|
16009
|
+
var CHANNEL_MESSAGE_TYPES = new Set([
|
|
16010
|
+
"join_channel",
|
|
16011
|
+
"leave_channel",
|
|
16012
|
+
"broadcast",
|
|
16013
|
+
"presence_track",
|
|
16014
|
+
"presence_untrack",
|
|
16015
|
+
"presence_state"
|
|
16016
|
+
]);
|
|
16017
|
+
/**
|
|
15942
16018
|
* Low-level realtime WebSocket client.
|
|
15943
16019
|
*
|
|
15944
16020
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
@@ -15953,6 +16029,36 @@ var RebaseWebSocketClient = class {
|
|
|
15953
16029
|
getAuthToken;
|
|
15954
16030
|
subscriptions = /* @__PURE__ */ new Map();
|
|
15955
16031
|
listeners = /* @__PURE__ */ new Map();
|
|
16032
|
+
/** Channel-name → handlers, for broadcast and presence frames. */
|
|
16033
|
+
channelHandlers = /* @__PURE__ */ new Map();
|
|
16034
|
+
/** Set by `close()`. Blocks any later operation from silently redialling. */
|
|
16035
|
+
closedByCaller = false;
|
|
16036
|
+
/**
|
|
16037
|
+
* Whether a socket exists at all (open or still opening).
|
|
16038
|
+
*
|
|
16039
|
+
* Lets callers distinguish "authenticate the live socket" from "there is
|
|
16040
|
+
* nothing to authenticate yet", without that question forcing a dial.
|
|
16041
|
+
*/
|
|
16042
|
+
get hasSocket() {
|
|
16043
|
+
return this.ws !== null;
|
|
16044
|
+
}
|
|
16045
|
+
/** So the "no WebSocket in this environment" warning is said once, not per call. */
|
|
16046
|
+
warnedNoWebSocket = false;
|
|
16047
|
+
/** Subscribe to broadcast/presence frames for one channel. */
|
|
16048
|
+
onChannelMessage(channel, handler) {
|
|
16049
|
+
if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, /* @__PURE__ */ new Set());
|
|
16050
|
+
this.channelHandlers.get(channel).add(handler);
|
|
16051
|
+
return () => {
|
|
16052
|
+
const handlers = this.channelHandlers.get(channel);
|
|
16053
|
+
if (!handlers) return;
|
|
16054
|
+
handlers.delete(handler);
|
|
16055
|
+
if (handlers.size === 0) this.channelHandlers.delete(channel);
|
|
16056
|
+
};
|
|
16057
|
+
}
|
|
16058
|
+
/** Notified after the socket comes back, so channels can re-join. */
|
|
16059
|
+
onReconnect(handler) {
|
|
16060
|
+
return this.on("reconnect", handler);
|
|
16061
|
+
}
|
|
15956
16062
|
on(event, cb) {
|
|
15957
16063
|
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
15958
16064
|
this.listeners.get(event).add(cb);
|
|
@@ -15971,6 +16077,7 @@ var RebaseWebSocketClient = class {
|
|
|
15971
16077
|
isConnected = false;
|
|
15972
16078
|
messageQueue = [];
|
|
15973
16079
|
requestTimeoutMs = 3e4;
|
|
16080
|
+
subscriptionTimeoutMs = 3e4;
|
|
15974
16081
|
reconnectTimeout = null;
|
|
15975
16082
|
isAuthenticated = false;
|
|
15976
16083
|
authPromise = null;
|
|
@@ -15982,8 +16089,25 @@ var RebaseWebSocketClient = class {
|
|
|
15982
16089
|
this.getAuthToken = config.getAuthToken;
|
|
15983
16090
|
this.onUnauthorized = config.onUnauthorized;
|
|
15984
16091
|
this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
|
|
15985
|
-
|
|
15986
|
-
|
|
16092
|
+
}
|
|
16093
|
+
/**
|
|
16094
|
+
* Open the socket if it is not open (or opening) already.
|
|
16095
|
+
*
|
|
16096
|
+
* Idempotent, synchronous, and safe to call on every operation that needs a
|
|
16097
|
+
* live socket — `initWebSocket` already no-ops on an open socket and is
|
|
16098
|
+
* re-entrant, since the reconnect path has always called it.
|
|
16099
|
+
*/
|
|
16100
|
+
ensureConnected() {
|
|
16101
|
+
if (this.closedByCaller) return;
|
|
16102
|
+
if (!this.WebSocketConstructor) {
|
|
16103
|
+
if (!this.warnedNoWebSocket) {
|
|
16104
|
+
this.warnedNoWebSocket = true;
|
|
16105
|
+
console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
|
|
16106
|
+
}
|
|
16107
|
+
return;
|
|
16108
|
+
}
|
|
16109
|
+
if (this.ws || this.reconnectTimeout) return;
|
|
16110
|
+
this.initWebSocket();
|
|
15987
16111
|
}
|
|
15988
16112
|
/**
|
|
15989
16113
|
* Authenticate the WebSocket connection
|
|
@@ -16033,7 +16157,16 @@ var RebaseWebSocketClient = class {
|
|
|
16033
16157
|
});
|
|
16034
16158
|
}
|
|
16035
16159
|
}
|
|
16036
|
-
|
|
16160
|
+
/**
|
|
16161
|
+
* Drop the socket.
|
|
16162
|
+
*
|
|
16163
|
+
* `permanent` distinguishes the two callers. Signing out drops the socket
|
|
16164
|
+
* but the client stays usable — a later subscribe should reconnect
|
|
16165
|
+
* anonymously. `client.close()` is the caller saying they are done, and
|
|
16166
|
+
* must not be undone by a stray queued frame.
|
|
16167
|
+
*/
|
|
16168
|
+
disconnect(permanent = false) {
|
|
16169
|
+
if (permanent) this.closedByCaller = true;
|
|
16037
16170
|
this.isAuthenticated = false;
|
|
16038
16171
|
this.authPromise = null;
|
|
16039
16172
|
if (this.reconnectTimeout) {
|
|
@@ -16076,6 +16209,7 @@ var RebaseWebSocketClient = class {
|
|
|
16076
16209
|
this.emit(wasReconnect ? "reconnect" : "connect");
|
|
16077
16210
|
this.processMessageQueue();
|
|
16078
16211
|
if (wasReconnect) this.resubscribeAll();
|
|
16212
|
+
this.armPendingSubscribeWatchdogs();
|
|
16079
16213
|
};
|
|
16080
16214
|
this.ws.onmessage = (event) => {
|
|
16081
16215
|
try {
|
|
@@ -16090,6 +16224,7 @@ var RebaseWebSocketClient = class {
|
|
|
16090
16224
|
this.isConnected = false;
|
|
16091
16225
|
this.isAuthenticated = false;
|
|
16092
16226
|
this.authPromise = null;
|
|
16227
|
+
this.suspendSubscribeWatchdogs();
|
|
16093
16228
|
this.emit("disconnect");
|
|
16094
16229
|
for (const [reqId, request] of this.pendingRequests.entries()) {
|
|
16095
16230
|
if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
|
|
@@ -16121,6 +16256,7 @@ var RebaseWebSocketClient = class {
|
|
|
16121
16256
|
attemptReconnect() {
|
|
16122
16257
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
16123
16258
|
console.error("Max reconnection attempts reached");
|
|
16259
|
+
this.failAllPendingSubscriptions(new RebaseApiError("Connection lost", { code: "CONNECTION_LOST" }));
|
|
16124
16260
|
return;
|
|
16125
16261
|
}
|
|
16126
16262
|
this.reconnectAttempts++;
|
|
@@ -16175,29 +16311,18 @@ var RebaseWebSocketClient = class {
|
|
|
16175
16311
|
subscription.backendSubscriptionId = newBackendId;
|
|
16176
16312
|
backendKeyMap.delete(oldBackendId);
|
|
16177
16313
|
backendKeyMap.set(newBackendId, subscriptionKey);
|
|
16178
|
-
this.
|
|
16179
|
-
|
|
16180
|
-
|
|
16181
|
-
...subscription.props,
|
|
16182
|
-
subscriptionId: newBackendId
|
|
16183
|
-
}
|
|
16184
|
-
}).catch((error) => {
|
|
16185
|
-
console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
|
|
16186
|
-
subscription.callbacks.forEach((callback) => {
|
|
16187
|
-
if (callback.onError) callback.onError(error);
|
|
16188
|
-
});
|
|
16189
|
-
});
|
|
16190
|
-
} else {
|
|
16191
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
16192
|
-
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
16193
|
-
subscription.callbacks.forEach((callback) => {
|
|
16194
|
-
if (callback.onError) callback.onError(error);
|
|
16195
|
-
});
|
|
16314
|
+
if (messageType === "subscribe_collection") this.sendCollectionSubscribe(subscriptionKey);
|
|
16315
|
+
else this.sendEntitySubscribe(subscriptionKey);
|
|
16316
|
+
return;
|
|
16196
16317
|
}
|
|
16318
|
+
const { errorMessage, errorCode } = extractMessageError(message);
|
|
16319
|
+
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
16320
|
+
if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
|
|
16321
|
+
else this.failEntitySubscription(subscriptionKey, error);
|
|
16197
16322
|
}).catch((err) => {
|
|
16198
|
-
|
|
16199
|
-
|
|
16200
|
-
|
|
16323
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
16324
|
+
if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
|
|
16325
|
+
else this.failEntitySubscription(subscriptionKey, error);
|
|
16201
16326
|
});
|
|
16202
16327
|
}
|
|
16203
16328
|
handleWebSocketMessage(message) {
|
|
@@ -16226,6 +16351,15 @@ var RebaseWebSocketClient = class {
|
|
|
16226
16351
|
}
|
|
16227
16352
|
return;
|
|
16228
16353
|
}
|
|
16354
|
+
if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff")) {
|
|
16355
|
+
const handlers = this.channelHandlers.get(message.channel);
|
|
16356
|
+
if (handlers) for (const handler of [...handlers]) try {
|
|
16357
|
+
handler(message);
|
|
16358
|
+
} catch (error) {
|
|
16359
|
+
console.error("Error in channel handler:", error);
|
|
16360
|
+
}
|
|
16361
|
+
return;
|
|
16362
|
+
}
|
|
16229
16363
|
if (subscriptionId && type === "collection_update") {
|
|
16230
16364
|
const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
16231
16365
|
if (subscriptionKey) {
|
|
@@ -16238,6 +16372,9 @@ var RebaseWebSocketClient = class {
|
|
|
16238
16372
|
collectionSub.latestData = rows;
|
|
16239
16373
|
collectionSub.lastUpdated = Date.now();
|
|
16240
16374
|
collectionSub.isInitialDataReceived = true;
|
|
16375
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
16376
|
+
collectionSub.subscribeTimeout = void 0;
|
|
16377
|
+
collectionSub.subscribeInFlight = false;
|
|
16241
16378
|
collectionSub.callbacks.forEach((callback) => {
|
|
16242
16379
|
try {
|
|
16243
16380
|
callback.onUpdate(rows);
|
|
@@ -16293,6 +16430,9 @@ var RebaseWebSocketClient = class {
|
|
|
16293
16430
|
entitySub.latestData = row;
|
|
16294
16431
|
entitySub.lastUpdated = Date.now();
|
|
16295
16432
|
entitySub.isInitialDataReceived = true;
|
|
16433
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
16434
|
+
entitySub.subscribeTimeout = void 0;
|
|
16435
|
+
entitySub.subscribeInFlight = false;
|
|
16296
16436
|
entitySub.callbacks.forEach((callback) => {
|
|
16297
16437
|
try {
|
|
16298
16438
|
callback.onUpdate(row);
|
|
@@ -16314,6 +16454,9 @@ var RebaseWebSocketClient = class {
|
|
|
16314
16454
|
this.resubscribeAfterAuthRefresh(message, collectionSub, collectionKey, "collection", this.backendToCollectionKey, "subscribe_collection");
|
|
16315
16455
|
return;
|
|
16316
16456
|
}
|
|
16457
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
16458
|
+
collectionSub.subscribeTimeout = void 0;
|
|
16459
|
+
collectionSub.subscribeInFlight = false;
|
|
16317
16460
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
16318
16461
|
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
16319
16462
|
collectionSub.callbacks.forEach((callback) => {
|
|
@@ -16330,6 +16473,9 @@ var RebaseWebSocketClient = class {
|
|
|
16330
16473
|
this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "row", this.backendToEntityKey, "subscribe_one");
|
|
16331
16474
|
return;
|
|
16332
16475
|
}
|
|
16476
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
16477
|
+
entitySub.subscribeTimeout = void 0;
|
|
16478
|
+
entitySub.subscribeInFlight = false;
|
|
16333
16479
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
16334
16480
|
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
16335
16481
|
entitySub.callbacks.forEach((callback) => {
|
|
@@ -16402,21 +16548,28 @@ var RebaseWebSocketClient = class {
|
|
|
16402
16548
|
throw error;
|
|
16403
16549
|
}
|
|
16404
16550
|
}
|
|
16551
|
+
/**
|
|
16552
|
+
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
|
16553
|
+
* Not part of the stable surface — prefer `client.realtime.channel(name)`.
|
|
16554
|
+
*/
|
|
16405
16555
|
sendMessage(message) {
|
|
16406
16556
|
const queuedMsg = message;
|
|
16407
16557
|
if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
|
|
16408
|
-
if (!this.isConnected || !this.ws)
|
|
16409
|
-
|
|
16410
|
-
|
|
16411
|
-
|
|
16412
|
-
|
|
16413
|
-
|
|
16558
|
+
if (!this.isConnected || !this.ws) {
|
|
16559
|
+
this.ensureConnected();
|
|
16560
|
+
return new Promise((resolve, reject) => {
|
|
16561
|
+
const queueable = message;
|
|
16562
|
+
queueable._queuedResolve = resolve;
|
|
16563
|
+
queueable._queuedReject = reject;
|
|
16564
|
+
this.messageQueue.push(message);
|
|
16565
|
+
});
|
|
16566
|
+
}
|
|
16414
16567
|
return new Promise((resolve, reject) => {
|
|
16415
16568
|
this.doSendMessage(message, resolve, reject);
|
|
16416
16569
|
});
|
|
16417
16570
|
}
|
|
16418
16571
|
async doSendMessage(message, resolve, reject) {
|
|
16419
|
-
if (message.type !== "AUTHENTICATE" && this.getAuthToken && !this.isAuthenticated) try {
|
|
16572
|
+
if (message.type !== "AUTHENTICATE" && !CHANNEL_MESSAGE_TYPES.has(message.type) && this.getAuthToken && !this.isAuthenticated) try {
|
|
16420
16573
|
await this.ensureAuthenticated();
|
|
16421
16574
|
} catch (error) {
|
|
16422
16575
|
reject(new RebaseApiError(error instanceof Error ? error.message : "Authentication required"));
|
|
@@ -16424,17 +16577,7 @@ var RebaseWebSocketClient = class {
|
|
|
16424
16577
|
}
|
|
16425
16578
|
const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
16426
16579
|
message.requestId = requestId;
|
|
16427
|
-
const expectsResponse = !
|
|
16428
|
-
"subscribe_collection",
|
|
16429
|
-
"subscribe_one",
|
|
16430
|
-
"unsubscribe",
|
|
16431
|
-
"join_channel",
|
|
16432
|
-
"leave_channel",
|
|
16433
|
-
"broadcast",
|
|
16434
|
-
"presence_track",
|
|
16435
|
-
"presence_untrack",
|
|
16436
|
-
"presence_state"
|
|
16437
|
-
].includes(message.type);
|
|
16580
|
+
const expectsResponse = !(message.type === "subscribe_collection" || message.type === "subscribe_one" || message.type === "unsubscribe" || CHANNEL_MESSAGE_TYPES.has(message.type));
|
|
16438
16581
|
if (expectsResponse && !this.pendingRequests.has(requestId)) {
|
|
16439
16582
|
const timeoutHandle = setTimeout(() => {
|
|
16440
16583
|
if (this.pendingRequests.has(requestId)) {
|
|
@@ -16663,6 +16806,7 @@ var RebaseWebSocketClient = class {
|
|
|
16663
16806
|
});
|
|
16664
16807
|
}
|
|
16665
16808
|
listenCollection(props, onUpdate, onError) {
|
|
16809
|
+
this.ensureConnected();
|
|
16666
16810
|
const subscriptionKey = this.createCollectionSubscriptionKey(props);
|
|
16667
16811
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
16668
16812
|
const existingSubscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
@@ -16678,9 +16822,12 @@ var RebaseWebSocketClient = class {
|
|
|
16678
16822
|
console.error("Error in collection subscription callback:", error);
|
|
16679
16823
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
16680
16824
|
}
|
|
16825
|
+
else if (!existingSubscription.subscribeInFlight) this.sendCollectionSubscribe(subscriptionKey);
|
|
16681
16826
|
return () => {
|
|
16682
16827
|
callbackMap.delete(callbackId);
|
|
16683
16828
|
if (callbackMap.size === 0) {
|
|
16829
|
+
if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
16830
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
16684
16831
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
16685
16832
|
this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
|
|
16686
16833
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -16702,21 +16849,14 @@ var RebaseWebSocketClient = class {
|
|
|
16702
16849
|
props
|
|
16703
16850
|
});
|
|
16704
16851
|
this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
|
|
16705
|
-
this.
|
|
16706
|
-
type: "subscribe_collection",
|
|
16707
|
-
payload: {
|
|
16708
|
-
...props,
|
|
16709
|
-
subscriptionId: backendSubscriptionId
|
|
16710
|
-
}
|
|
16711
|
-
}).catch((error) => {
|
|
16712
|
-
if (onError) onError(error);
|
|
16713
|
-
});
|
|
16852
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
16714
16853
|
return () => {
|
|
16715
16854
|
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
16716
16855
|
if (subscription) {
|
|
16717
16856
|
const callbacks = subscription.callbacks;
|
|
16718
16857
|
callbacks.delete(callbackId);
|
|
16719
16858
|
if (callbacks.size === 0) {
|
|
16859
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
16720
16860
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
16721
16861
|
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
16722
16862
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -16728,6 +16868,7 @@ var RebaseWebSocketClient = class {
|
|
|
16728
16868
|
};
|
|
16729
16869
|
}
|
|
16730
16870
|
listenOne(props, onUpdate, onError) {
|
|
16871
|
+
this.ensureConnected();
|
|
16731
16872
|
const subscriptionKey = this.createSingleSubscriptionKey(props);
|
|
16732
16873
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
16733
16874
|
const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
|
|
@@ -16743,9 +16884,12 @@ var RebaseWebSocketClient = class {
|
|
|
16743
16884
|
console.error("Error in row subscription callback:", error);
|
|
16744
16885
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
16745
16886
|
}
|
|
16887
|
+
else if (!existingSubscription.subscribeInFlight) this.sendEntitySubscribe(subscriptionKey);
|
|
16746
16888
|
return () => {
|
|
16747
16889
|
callbackMap.delete(callbackId);
|
|
16748
16890
|
if (callbackMap.size === 0) {
|
|
16891
|
+
if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
16892
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
16749
16893
|
this.singleSubscriptions.delete(subscriptionKey);
|
|
16750
16894
|
this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
|
|
16751
16895
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -16767,15 +16911,7 @@ var RebaseWebSocketClient = class {
|
|
|
16767
16911
|
props
|
|
16768
16912
|
});
|
|
16769
16913
|
this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
|
|
16770
|
-
this.
|
|
16771
|
-
type: "subscribe_one",
|
|
16772
|
-
payload: {
|
|
16773
|
-
...props,
|
|
16774
|
-
subscriptionId: backendSubscriptionId
|
|
16775
|
-
}
|
|
16776
|
-
}).catch((error) => {
|
|
16777
|
-
if (onError) onError(error);
|
|
16778
|
-
});
|
|
16914
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
16779
16915
|
return () => {
|
|
16780
16916
|
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
16781
16917
|
if (subscription) {
|
|
@@ -16793,6 +16929,157 @@ var RebaseWebSocketClient = class {
|
|
|
16793
16929
|
};
|
|
16794
16930
|
}
|
|
16795
16931
|
/**
|
|
16932
|
+
* Send a `subscribe_collection` for an already-registered subscription and
|
|
16933
|
+
* arm its watchdog.
|
|
16934
|
+
*
|
|
16935
|
+
* Every path that registers a collection subscription goes through here, so
|
|
16936
|
+
* that a subscribe which never lands — a rejected send, or a server that
|
|
16937
|
+
* never answers — always ends up in `failCollectionSubscription` rather than
|
|
16938
|
+
* leaving the entry parked with `isInitialDataReceived === false` forever.
|
|
16939
|
+
*/
|
|
16940
|
+
sendCollectionSubscribe(subscriptionKey) {
|
|
16941
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
16942
|
+
if (!subscription) return;
|
|
16943
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
16944
|
+
subscription.subscribeInFlight = true;
|
|
16945
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
16946
|
+
subscription.subscribeTimeout = void 0;
|
|
16947
|
+
if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
|
|
16948
|
+
this.sendMessage({
|
|
16949
|
+
type: "subscribe_collection",
|
|
16950
|
+
payload: {
|
|
16951
|
+
...subscription.props,
|
|
16952
|
+
subscriptionId: backendSubscriptionId
|
|
16953
|
+
}
|
|
16954
|
+
}).catch((error) => {
|
|
16955
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
16956
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
16957
|
+
this.failCollectionSubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
|
|
16958
|
+
});
|
|
16959
|
+
}
|
|
16960
|
+
/** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
|
|
16961
|
+
sendEntitySubscribe(subscriptionKey) {
|
|
16962
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
16963
|
+
if (!subscription) return;
|
|
16964
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
16965
|
+
subscription.subscribeInFlight = true;
|
|
16966
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
16967
|
+
subscription.subscribeTimeout = void 0;
|
|
16968
|
+
if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
|
|
16969
|
+
this.sendMessage({
|
|
16970
|
+
type: "subscribe_one",
|
|
16971
|
+
payload: {
|
|
16972
|
+
...subscription.props,
|
|
16973
|
+
subscriptionId: backendSubscriptionId
|
|
16974
|
+
}
|
|
16975
|
+
}).catch((error) => {
|
|
16976
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
16977
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
16978
|
+
this.failEntitySubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
|
|
16979
|
+
});
|
|
16980
|
+
}
|
|
16981
|
+
/**
|
|
16982
|
+
* Report a subscribe failure to every listener and drop the registration.
|
|
16983
|
+
*
|
|
16984
|
+
* Dropping it is the point: the callbacks stay live (their components are
|
|
16985
|
+
* still mounted and have been told), but the next `listenCollection` for
|
|
16986
|
+
* these params finds no entry and issues a fresh subscribe instead of
|
|
16987
|
+
* silently attaching to a dead one.
|
|
16988
|
+
*/
|
|
16989
|
+
failCollectionSubscription(subscriptionKey, error) {
|
|
16990
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
16991
|
+
if (!subscription) return;
|
|
16992
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
16993
|
+
subscription.subscribeInFlight = false;
|
|
16994
|
+
this.collectionSubscriptions.delete(subscriptionKey);
|
|
16995
|
+
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
16996
|
+
subscription.callbacks.forEach((callback) => {
|
|
16997
|
+
if (callback.onError) try {
|
|
16998
|
+
callback.onError(error);
|
|
16999
|
+
} catch (callbackError) {
|
|
17000
|
+
console.error("Error in collection subscription error callback:", callbackError);
|
|
17001
|
+
}
|
|
17002
|
+
});
|
|
17003
|
+
}
|
|
17004
|
+
/** The `listenOne` counterpart of {@link failCollectionSubscription}. */
|
|
17005
|
+
failEntitySubscription(subscriptionKey, error) {
|
|
17006
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
17007
|
+
if (!subscription) return;
|
|
17008
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
17009
|
+
subscription.subscribeInFlight = false;
|
|
17010
|
+
this.singleSubscriptions.delete(subscriptionKey);
|
|
17011
|
+
this.backendToEntityKey.delete(subscription.backendSubscriptionId);
|
|
17012
|
+
subscription.callbacks.forEach((callback) => {
|
|
17013
|
+
if (callback.onError) try {
|
|
17014
|
+
callback.onError(error);
|
|
17015
|
+
} catch (callbackError) {
|
|
17016
|
+
console.error("Error in row subscription error callback:", callbackError);
|
|
17017
|
+
}
|
|
17018
|
+
});
|
|
17019
|
+
}
|
|
17020
|
+
/**
|
|
17021
|
+
* Stop the watchdogs without failing anything — used when the socket drops,
|
|
17022
|
+
* since the reconnect path re-subscribes everything anyway and a watchdog
|
|
17023
|
+
* firing mid-reconnect would tear down healthy subscriptions.
|
|
17024
|
+
*/
|
|
17025
|
+
suspendSubscribeWatchdogs() {
|
|
17026
|
+
for (const sub of this.collectionSubscriptions.values()) {
|
|
17027
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
17028
|
+
sub.subscribeTimeout = void 0;
|
|
17029
|
+
sub.subscribeInFlight = false;
|
|
17030
|
+
}
|
|
17031
|
+
for (const sub of this.singleSubscriptions.values()) {
|
|
17032
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
17033
|
+
sub.subscribeTimeout = void 0;
|
|
17034
|
+
sub.subscribeInFlight = false;
|
|
17035
|
+
}
|
|
17036
|
+
}
|
|
17037
|
+
/**
|
|
17038
|
+
* Arm watchdogs for subscribes that were requested while offline and have
|
|
17039
|
+
* just been flushed to the socket. Their timers were deliberately not set at
|
|
17040
|
+
* request time, so without this they would have no timeout at all.
|
|
17041
|
+
*/
|
|
17042
|
+
armPendingSubscribeWatchdogs() {
|
|
17043
|
+
for (const [key, sub] of this.collectionSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
|
|
17044
|
+
for (const [key, sub] of this.singleSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
|
|
17045
|
+
}
|
|
17046
|
+
sendCollectionSubscribeWatchdog(subscriptionKey) {
|
|
17047
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
17048
|
+
if (!subscription) return;
|
|
17049
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
17050
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
17051
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
17052
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
17053
|
+
if (!current.subscribeInFlight) return;
|
|
17054
|
+
this.failCollectionSubscription(subscriptionKey, new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
|
|
17055
|
+
}, this.subscriptionTimeoutMs);
|
|
17056
|
+
}
|
|
17057
|
+
sendEntitySubscribeWatchdog(subscriptionKey) {
|
|
17058
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
17059
|
+
if (!subscription) return;
|
|
17060
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
17061
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
17062
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
17063
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
17064
|
+
if (!current.subscribeInFlight) return;
|
|
17065
|
+
this.failEntitySubscription(subscriptionKey, new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
|
|
17066
|
+
}, this.subscriptionTimeoutMs);
|
|
17067
|
+
}
|
|
17068
|
+
/**
|
|
17069
|
+
* Fail every subscription that never received data. Called when reconnection
|
|
17070
|
+
* is given up on, so views surface an error instead of spinning forever.
|
|
17071
|
+
*/
|
|
17072
|
+
failAllPendingSubscriptions(error) {
|
|
17073
|
+
for (const key of [...this.collectionSubscriptions.keys()]) {
|
|
17074
|
+
const sub = this.collectionSubscriptions.get(key);
|
|
17075
|
+
if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
|
|
17076
|
+
}
|
|
17077
|
+
for (const key of [...this.singleSubscriptions.keys()]) {
|
|
17078
|
+
const sub = this.singleSubscriptions.get(key);
|
|
17079
|
+
if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
|
|
17080
|
+
}
|
|
17081
|
+
}
|
|
17082
|
+
/**
|
|
16796
17083
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
16797
17084
|
* The server wipes subscription state when a client disconnects, so
|
|
16798
17085
|
* we need to re-register everything to resume receiving updates.
|
|
@@ -16805,15 +17092,7 @@ var RebaseWebSocketClient = class {
|
|
|
16805
17092
|
sub.backendSubscriptionId = newBackendId;
|
|
16806
17093
|
this.backendToCollectionKey.delete(oldBackendId);
|
|
16807
17094
|
this.backendToCollectionKey.set(newBackendId, key);
|
|
16808
|
-
this.
|
|
16809
|
-
type: "subscribe_collection",
|
|
16810
|
-
payload: {
|
|
16811
|
-
...sub.props,
|
|
16812
|
-
subscriptionId: newBackendId
|
|
16813
|
-
}
|
|
16814
|
-
}).catch((error) => {
|
|
16815
|
-
console.error("[WS] Failed to re-subscribe collection:", key, error);
|
|
16816
|
-
});
|
|
17095
|
+
this.sendCollectionSubscribe(key);
|
|
16817
17096
|
}
|
|
16818
17097
|
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
16819
17098
|
const oldBackendId = sub.backendSubscriptionId;
|
|
@@ -16821,15 +17100,7 @@ var RebaseWebSocketClient = class {
|
|
|
16821
17100
|
sub.backendSubscriptionId = newBackendId;
|
|
16822
17101
|
this.backendToEntityKey.delete(oldBackendId);
|
|
16823
17102
|
this.backendToEntityKey.set(newBackendId, key);
|
|
16824
|
-
this.
|
|
16825
|
-
type: "subscribe_one",
|
|
16826
|
-
payload: {
|
|
16827
|
-
...sub.props,
|
|
16828
|
-
subscriptionId: newBackendId
|
|
16829
|
-
}
|
|
16830
|
-
}).catch((error) => {
|
|
16831
|
-
console.error("[WS] Failed to re-subscribe row:", key, error);
|
|
16832
|
-
});
|
|
17103
|
+
this.sendEntitySubscribe(key);
|
|
16833
17104
|
}
|
|
16834
17105
|
}
|
|
16835
17106
|
createCollectionSubscriptionKey(props) {
|
|
@@ -16856,6 +17127,177 @@ var RebaseWebSocketClient = class {
|
|
|
16856
17127
|
}
|
|
16857
17128
|
};
|
|
16858
17129
|
/**
|
|
17130
|
+
* Re-send presence comfortably inside the server's 30s expiry.
|
|
17131
|
+
*
|
|
17132
|
+
* Two-thirds of the window: one lost heartbeat still leaves time for the next
|
|
17133
|
+
* before the entry is reaped, so a single dropped frame is not a disappearance.
|
|
17134
|
+
*/
|
|
17135
|
+
var PRESENCE_HEARTBEAT_MS = 2e4;
|
|
17136
|
+
var RebaseRealtimeChannel = class {
|
|
17137
|
+
name;
|
|
17138
|
+
transport;
|
|
17139
|
+
presenceHandlers = /* @__PURE__ */ new Set();
|
|
17140
|
+
broadcastHandlers = /* @__PURE__ */ new Set();
|
|
17141
|
+
unsubscribers = [];
|
|
17142
|
+
/** Last known roster, kept so handlers always get a full picture. */
|
|
17143
|
+
presences = {};
|
|
17144
|
+
/** What this client last tracked, replayed on reconnect and heartbeat. */
|
|
17145
|
+
trackedState = null;
|
|
17146
|
+
heartbeat = null;
|
|
17147
|
+
joined = false;
|
|
17148
|
+
constructor(name, transport) {
|
|
17149
|
+
this.name = name;
|
|
17150
|
+
this.transport = transport;
|
|
17151
|
+
}
|
|
17152
|
+
/**
|
|
17153
|
+
* Join the channel and ask for the current roster.
|
|
17154
|
+
*
|
|
17155
|
+
* Called automatically by `track`, `broadcast`, `onPresence` and
|
|
17156
|
+
* `onBroadcast`; calling it directly is only needed to start receiving
|
|
17157
|
+
* before there is anything to send.
|
|
17158
|
+
*/
|
|
17159
|
+
/**
|
|
17160
|
+
* Send a channel message.
|
|
17161
|
+
*
|
|
17162
|
+
* Every channel message is read by the server out of a `payload` envelope
|
|
17163
|
+
* (`payload?.channel`, `payload?.state`, `payload?.event`). Sending those
|
|
17164
|
+
* fields flat does not error: `payload?.channel` simply reads as
|
|
17165
|
+
* `undefined`, so the client is registered into channel `undefined` with
|
|
17166
|
+
* empty state, and the echo comes back with no `channel` for
|
|
17167
|
+
* `onChannelMessage` to match — presence and broadcast both go quiet with
|
|
17168
|
+
* nothing logged. Funnelled through one place so a new message type cannot
|
|
17169
|
+
* reintroduce that.
|
|
17170
|
+
*/
|
|
17171
|
+
send(type, fields = {}) {
|
|
17172
|
+
return this.transport.sendMessage({
|
|
17173
|
+
type,
|
|
17174
|
+
payload: {
|
|
17175
|
+
channel: this.name,
|
|
17176
|
+
...fields
|
|
17177
|
+
}
|
|
17178
|
+
});
|
|
17179
|
+
}
|
|
17180
|
+
async join() {
|
|
17181
|
+
if (this.joined) return;
|
|
17182
|
+
this.joined = true;
|
|
17183
|
+
this.unsubscribers.push(this.transport.onChannelMessage(this.name, (message) => this.handle(message)));
|
|
17184
|
+
this.unsubscribers.push(this.transport.onReconnect(() => {
|
|
17185
|
+
this.rejoin();
|
|
17186
|
+
}));
|
|
17187
|
+
await this.send("join_channel");
|
|
17188
|
+
await this.send("presence_state");
|
|
17189
|
+
}
|
|
17190
|
+
async rejoin() {
|
|
17191
|
+
try {
|
|
17192
|
+
await this.send("join_channel");
|
|
17193
|
+
await this.send("presence_state");
|
|
17194
|
+
if (this.trackedState) await this.send("presence_track", { state: this.trackedState });
|
|
17195
|
+
} catch {}
|
|
17196
|
+
}
|
|
17197
|
+
/**
|
|
17198
|
+
* Publish this client's presence state, and keep publishing it.
|
|
17199
|
+
*
|
|
17200
|
+
* Calling `track` again replaces the state (and restarts the heartbeat),
|
|
17201
|
+
* which is how you update e.g. a cursor position.
|
|
17202
|
+
*/
|
|
17203
|
+
async track(state) {
|
|
17204
|
+
await this.join();
|
|
17205
|
+
this.trackedState = state;
|
|
17206
|
+
await this.send("presence_track", { state });
|
|
17207
|
+
if (!this.heartbeat) {
|
|
17208
|
+
this.heartbeat = setInterval(() => {
|
|
17209
|
+
if (!this.trackedState) return;
|
|
17210
|
+
this.send("presence_track", { state: this.trackedState }).catch(() => {});
|
|
17211
|
+
}, PRESENCE_HEARTBEAT_MS);
|
|
17212
|
+
this.heartbeat.unref?.();
|
|
17213
|
+
}
|
|
17214
|
+
}
|
|
17215
|
+
/** Stop publishing presence, without leaving the channel. */
|
|
17216
|
+
async untrack() {
|
|
17217
|
+
this.stopHeartbeat();
|
|
17218
|
+
this.trackedState = null;
|
|
17219
|
+
if (this.joined) await this.send("presence_untrack");
|
|
17220
|
+
}
|
|
17221
|
+
/**
|
|
17222
|
+
* Observe the roster. The handler fires immediately with what is already
|
|
17223
|
+
* known, then on every change.
|
|
17224
|
+
*/
|
|
17225
|
+
onPresence(handler) {
|
|
17226
|
+
this.presenceHandlers.add(handler);
|
|
17227
|
+
this.join();
|
|
17228
|
+
if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
|
|
17229
|
+
return () => this.presenceHandlers.delete(handler);
|
|
17230
|
+
}
|
|
17231
|
+
/** Send a broadcast. The sender does not receive its own message. */
|
|
17232
|
+
async broadcast(event, payload) {
|
|
17233
|
+
await this.join();
|
|
17234
|
+
await this.send("broadcast", {
|
|
17235
|
+
event,
|
|
17236
|
+
payload
|
|
17237
|
+
});
|
|
17238
|
+
}
|
|
17239
|
+
onBroadcast(eventOrHandler, maybeHandler) {
|
|
17240
|
+
const wrapped = typeof eventOrHandler === "string" ? (e) => {
|
|
17241
|
+
if (e.event === eventOrHandler) maybeHandler(e.payload);
|
|
17242
|
+
} : eventOrHandler;
|
|
17243
|
+
this.broadcastHandlers.add(wrapped);
|
|
17244
|
+
this.join();
|
|
17245
|
+
return () => this.broadcastHandlers.delete(wrapped);
|
|
17246
|
+
}
|
|
17247
|
+
/** Leave the channel and release every listener and timer. */
|
|
17248
|
+
async leave() {
|
|
17249
|
+
this.stopHeartbeat();
|
|
17250
|
+
this.trackedState = null;
|
|
17251
|
+
this.presences = {};
|
|
17252
|
+
this.presenceHandlers.clear();
|
|
17253
|
+
this.broadcastHandlers.clear();
|
|
17254
|
+
for (const off of this.unsubscribers) off();
|
|
17255
|
+
this.unsubscribers = [];
|
|
17256
|
+
if (this.joined) {
|
|
17257
|
+
this.joined = false;
|
|
17258
|
+
await this.send("leave_channel");
|
|
17259
|
+
}
|
|
17260
|
+
}
|
|
17261
|
+
stopHeartbeat() {
|
|
17262
|
+
if (this.heartbeat) {
|
|
17263
|
+
clearInterval(this.heartbeat);
|
|
17264
|
+
this.heartbeat = null;
|
|
17265
|
+
}
|
|
17266
|
+
}
|
|
17267
|
+
/** Fold an incoming frame into the roster and fan it out. */
|
|
17268
|
+
handle(message) {
|
|
17269
|
+
switch (message.type) {
|
|
17270
|
+
case "presence_state":
|
|
17271
|
+
this.presences = message.presences ?? {};
|
|
17272
|
+
this.emitPresence();
|
|
17273
|
+
break;
|
|
17274
|
+
case "presence_diff": {
|
|
17275
|
+
const joins = message.joins ?? {};
|
|
17276
|
+
const leaves = message.leaves ?? {};
|
|
17277
|
+
for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
|
|
17278
|
+
for (const id of Object.keys(leaves)) delete this.presences[id];
|
|
17279
|
+
this.emitPresence({
|
|
17280
|
+
joins,
|
|
17281
|
+
leaves
|
|
17282
|
+
});
|
|
17283
|
+
break;
|
|
17284
|
+
}
|
|
17285
|
+
case "broadcast": {
|
|
17286
|
+
const event = {
|
|
17287
|
+
event: message.event,
|
|
17288
|
+
payload: message.payload
|
|
17289
|
+
};
|
|
17290
|
+
for (const handler of this.broadcastHandlers) handler(event);
|
|
17291
|
+
break;
|
|
17292
|
+
}
|
|
17293
|
+
}
|
|
17294
|
+
}
|
|
17295
|
+
emitPresence(diff) {
|
|
17296
|
+
const snapshot = { ...this.presences };
|
|
17297
|
+
for (const handler of this.presenceHandlers) handler(snapshot, diff);
|
|
17298
|
+
}
|
|
17299
|
+
};
|
|
17300
|
+
/**
|
|
16859
17301
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
16860
17302
|
* `http://` → `ws://`, `https://` → `wss://`.
|
|
16861
17303
|
*/
|
|
@@ -16904,6 +17346,8 @@ function createRebaseClient(options) {
|
|
|
16904
17346
|
};
|
|
16905
17347
|
const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
|
|
16906
17348
|
let ws;
|
|
17349
|
+
/** One channel object per name — see `realtime.channel`. */
|
|
17350
|
+
const realtimeChannels = /* @__PURE__ */ new Map();
|
|
16907
17351
|
if (resolvedWsUrl) {
|
|
16908
17352
|
ws = new RebaseWebSocketClient({
|
|
16909
17353
|
websocketUrl: resolvedWsUrl,
|
|
@@ -16927,7 +17371,7 @@ function createRebaseClient(options) {
|
|
|
16927
17371
|
if (!ws) return;
|
|
16928
17372
|
if (event === "SIGNED_OUT") ws.disconnect();
|
|
16929
17373
|
else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
|
16930
|
-
if (session?.accessToken) ws.authenticate(session.accessToken).catch(console.warn);
|
|
17374
|
+
if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
|
|
16931
17375
|
}
|
|
16932
17376
|
});
|
|
16933
17377
|
}
|
|
@@ -17014,6 +17458,24 @@ function createRebaseClient(options) {
|
|
|
17014
17458
|
createStorageSource,
|
|
17015
17459
|
fetchStorageSources,
|
|
17016
17460
|
ws,
|
|
17461
|
+
realtime: {
|
|
17462
|
+
/**
|
|
17463
|
+
* Join a broadcast/presence channel.
|
|
17464
|
+
*
|
|
17465
|
+
* Repeated calls with the same name return the same channel, so
|
|
17466
|
+
* separate components can attach handlers without each opening its
|
|
17467
|
+
* own membership — and `leave()` from one would otherwise silently
|
|
17468
|
+
* cut off the others.
|
|
17469
|
+
*/
|
|
17470
|
+
channel: (name) => {
|
|
17471
|
+
if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
17472
|
+
let existing = realtimeChannels.get(name);
|
|
17473
|
+
if (!existing) {
|
|
17474
|
+
existing = new RebaseRealtimeChannel(name, ws);
|
|
17475
|
+
realtimeChannels.set(name, existing);
|
|
17476
|
+
}
|
|
17477
|
+
return existing;
|
|
17478
|
+
} },
|
|
17017
17479
|
/**
|
|
17018
17480
|
* Release the realtime socket and its reconnect timer.
|
|
17019
17481
|
*
|
|
@@ -17022,7 +17484,9 @@ function createRebaseClient(options) {
|
|
|
17022
17484
|
* was never started, and safe to call twice.
|
|
17023
17485
|
*/
|
|
17024
17486
|
close: () => {
|
|
17025
|
-
|
|
17487
|
+
for (const channel of realtimeChannels.values()) channel.leave();
|
|
17488
|
+
realtimeChannels.clear();
|
|
17489
|
+
ws?.disconnect(true);
|
|
17026
17490
|
},
|
|
17027
17491
|
setToken: transport.setToken,
|
|
17028
17492
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
@@ -17663,7 +18127,8 @@ async function _initializeRebaseBackend(config) {
|
|
|
17663
18127
|
registry: storageRegistry,
|
|
17664
18128
|
sources: config.storageSources,
|
|
17665
18129
|
requireAuth: resolveRequireAuth(config.auth),
|
|
17666
|
-
authAdapter
|
|
18130
|
+
authAdapter,
|
|
18131
|
+
authorize: config.storageAuthorize
|
|
17667
18132
|
});
|
|
17668
18133
|
const storageRouter = new Hono();
|
|
17669
18134
|
if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
|
|
@@ -17932,7 +18397,7 @@ async function loadFunctionsFromDirectory(directory, importModule = nativeDynami
|
|
|
17932
18397
|
const problems = [];
|
|
17933
18398
|
if (!fs$2.existsSync(directory)) return functions;
|
|
17934
18399
|
const files = fs$2.readdirSync(directory);
|
|
17935
|
-
for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
|
|
18400
|
+
for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.startsWith(".") && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
|
|
17936
18401
|
const filePath = path$1.join(directory, file);
|
|
17937
18402
|
try {
|
|
17938
18403
|
const fileUrl = pathToFileURL(filePath).href;
|
|
@@ -18061,7 +18526,7 @@ async function loadCronJobsFromDirectory(directory, importModule = nativeDynamic
|
|
|
18061
18526
|
const jobs = [];
|
|
18062
18527
|
if (!fs$2.existsSync(directory)) return jobs;
|
|
18063
18528
|
const files = fs$2.readdirSync(directory);
|
|
18064
|
-
for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
|
|
18529
|
+
for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.startsWith(".") && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
|
|
18065
18530
|
const filePath = path$1.join(directory, file);
|
|
18066
18531
|
try {
|
|
18067
18532
|
const fileUrl = pathToFileURL(filePath).href;
|
|
@@ -18335,6 +18800,7 @@ var CronScheduler = class {
|
|
|
18335
18800
|
logger.warn("[cron] Failed to seed job stats from database", { error: err });
|
|
18336
18801
|
});
|
|
18337
18802
|
for (const [id, job] of this.jobs) if (job.enabled) this.scheduleNext(id);
|
|
18803
|
+
if (!this.store) logger.warn("[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job");
|
|
18338
18804
|
logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
|
|
18339
18805
|
}
|
|
18340
18806
|
/**
|
|
@@ -18453,6 +18919,19 @@ var CronScheduler = class {
|
|
|
18453
18919
|
this.scheduleNext(id);
|
|
18454
18920
|
return;
|
|
18455
18921
|
}
|
|
18922
|
+
if (this.store?.tryClaimRun) {
|
|
18923
|
+
let claimed = true;
|
|
18924
|
+
try {
|
|
18925
|
+
claimed = await this.store.tryClaimRun(id, nextRun.toISOString());
|
|
18926
|
+
} catch (err) {
|
|
18927
|
+
logger.warn(`[cron] Claim check threw for "${id}" — running uncoordinated`, { error: err });
|
|
18928
|
+
}
|
|
18929
|
+
if (!claimed) {
|
|
18930
|
+
logger.info(`[cron] Slot ${nextRun.toISOString()} for "${id}" claimed by another instance — skipping`);
|
|
18931
|
+
if (this.started && job.enabled) this.scheduleNext(id);
|
|
18932
|
+
return;
|
|
18933
|
+
}
|
|
18934
|
+
}
|
|
18456
18935
|
await this.executeJob(job, false);
|
|
18457
18936
|
if (this.started && job.enabled) this.scheduleNext(id);
|
|
18458
18937
|
}, Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS));
|
|
@@ -18621,6 +19100,25 @@ function createCronRoutes(scheduler) {
|
|
|
18621
19100
|
//#region src/cron/cron-store.ts
|
|
18622
19101
|
var cron_store_exports = /* @__PURE__ */ __exportAll({ createCronStore: () => createCronStore });
|
|
18623
19102
|
var TABLE = "rebase.cron_logs";
|
|
19103
|
+
var CLAIMS_TABLE = "rebase.cron_claims";
|
|
19104
|
+
/** Claims older than this are garbage-collected on startup. */
|
|
19105
|
+
var CLAIM_RETENTION_DAYS = 7;
|
|
19106
|
+
/**
|
|
19107
|
+
* Detect a unique-constraint violation anywhere in an error's cause chain.
|
|
19108
|
+
* Drizzle wraps the PG error — match the SQLSTATE code, never message text.
|
|
19109
|
+
* Also covers SQLite ("UNIQUE constraint failed") and MySQL (ER_DUP_ENTRY 1062)
|
|
19110
|
+
* for future SQL drivers.
|
|
19111
|
+
*/
|
|
19112
|
+
function isUniqueViolation(err) {
|
|
19113
|
+
let current = err;
|
|
19114
|
+
for (let depth = 0; depth < 10 && current; depth++) if (typeof current === "object") {
|
|
19115
|
+
const e = current;
|
|
19116
|
+
if (e.code === "23505" || e.errno === 1062) return true;
|
|
19117
|
+
if (typeof e.message === "string" && e.message.includes("UNIQUE constraint failed")) return true;
|
|
19118
|
+
current = e.cause;
|
|
19119
|
+
} else break;
|
|
19120
|
+
return false;
|
|
19121
|
+
}
|
|
18624
19122
|
function createCronStore(driver) {
|
|
18625
19123
|
const admin = driver.admin;
|
|
18626
19124
|
if (!isSQLAdmin(admin)) {
|
|
@@ -18650,6 +19148,15 @@ function createCronStore(driver) {
|
|
|
18650
19148
|
CREATE INDEX IF NOT EXISTS idx_cron_logs_job
|
|
18651
19149
|
ON ${TABLE}(job_id, started_at DESC)
|
|
18652
19150
|
`);
|
|
19151
|
+
await exec(`
|
|
19152
|
+
CREATE TABLE IF NOT EXISTS ${CLAIMS_TABLE} (
|
|
19153
|
+
job_id TEXT NOT NULL,
|
|
19154
|
+
slot TIMESTAMPTZ NOT NULL,
|
|
19155
|
+
claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
19156
|
+
PRIMARY KEY (job_id, slot)
|
|
19157
|
+
)
|
|
19158
|
+
`);
|
|
19159
|
+
await exec(`DELETE FROM ${CLAIMS_TABLE} WHERE claimed_at < now() - make_interval(days => $1)`, { params: [CLAIM_RETENTION_DAYS] });
|
|
18653
19160
|
logger.info("✅ Cron logs table ready");
|
|
18654
19161
|
} catch (err) {
|
|
18655
19162
|
logger.error("❌ Failed to create cron logs table", { error: err });
|
|
@@ -18709,6 +19216,18 @@ function createCronStore(driver) {
|
|
|
18709
19216
|
logger.error("[cron-store] Failed to fetch job stats", { error: err });
|
|
18710
19217
|
}
|
|
18711
19218
|
return stats;
|
|
19219
|
+
},
|
|
19220
|
+
async tryClaimRun(jobId, slot) {
|
|
19221
|
+
try {
|
|
19222
|
+
return (await exec(`INSERT INTO ${CLAIMS_TABLE} (job_id, slot)
|
|
19223
|
+
VALUES ($1, $2)
|
|
19224
|
+
ON CONFLICT (job_id, slot) DO NOTHING
|
|
19225
|
+
RETURNING job_id`, { params: [jobId, slot] })).length > 0;
|
|
19226
|
+
} catch (err) {
|
|
19227
|
+
if (isUniqueViolation(err)) return false;
|
|
19228
|
+
logger.warn(`[cron-store] Claim check failed for "${jobId}" — running uncoordinated`, { error: err });
|
|
19229
|
+
return true;
|
|
19230
|
+
}
|
|
18712
19231
|
}
|
|
18713
19232
|
};
|
|
18714
19233
|
}
|
|
@@ -19008,7 +19527,10 @@ var rebaseEnvSchema = object({
|
|
|
19008
19527
|
S3_ACCESS_KEY_ID: string().optional(),
|
|
19009
19528
|
S3_SECRET_ACCESS_KEY: string().optional(),
|
|
19010
19529
|
S3_ENDPOINT: string().url().optional(),
|
|
19011
|
-
S3_FORCE_PATH_STYLE: optionalBoolString
|
|
19530
|
+
S3_FORCE_PATH_STYLE: optionalBoolString,
|
|
19531
|
+
GCS_BUCKET: string().optional(),
|
|
19532
|
+
GCS_PROJECT_ID: string().optional(),
|
|
19533
|
+
GCS_KEY_FILENAME: string().optional()
|
|
19012
19534
|
});
|
|
19013
19535
|
function loadEnv(options) {
|
|
19014
19536
|
const isProduction = process.env.NODE_ENV === "production";
|