@spooky-sync/core 0.0.1-canary.197 → 0.0.1-canary.199
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.d.ts +59 -3
- package/dist/index.js +164 -9
- package/dist/types.d.ts +16 -0
- package/package.json +4 -3
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/index.ts +11 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.ts +32 -1
- package/src/sp00ky.ts +123 -4
- package/src/types.ts +13 -0
- package/src/utils/blurhash.ts +90 -0
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as surrealdb0 from "surrealdb";
|
|
|
3
3
|
import { Duration, RecordId, Surreal as Surreal$1, SurrealEvents, SurrealTransaction } from "surrealdb";
|
|
4
4
|
import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, FinalQuery, GetTable, QueryBuilder, QueryOptions, QueryPlan, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
|
|
5
5
|
import { Logger } from "pino";
|
|
6
|
+
import { decode, encode, isBlurhashValid } from "blurhash";
|
|
6
7
|
import { LoroDoc } from "loro-crdt";
|
|
7
8
|
|
|
8
9
|
//#region src/services/database/database.d.ts
|
|
@@ -2059,17 +2060,72 @@ declare class BlobCache {
|
|
|
2059
2060
|
stats(): BlobCacheStats;
|
|
2060
2061
|
}
|
|
2061
2062
|
//#endregion
|
|
2063
|
+
//#region src/utils/blurhash.d.ts
|
|
2064
|
+
/**
|
|
2065
|
+
* Blurhash generation settings. `true` enables with the defaults below, `false`
|
|
2066
|
+
* disables. Resolution order for a put: per-call option > client config >
|
|
2067
|
+
* default ON. See {@link Sp00kyConfig.blurhash}.
|
|
2068
|
+
*/
|
|
2069
|
+
type BlurhashSetting = boolean | BlurhashEncodeOptions;
|
|
2070
|
+
interface BlurhashEncodeOptions {
|
|
2071
|
+
/** Horizontal detail components, 1-9. Defaults to 4. */
|
|
2072
|
+
componentX?: number;
|
|
2073
|
+
/** Vertical detail components, 1-9. Defaults to 3. */
|
|
2074
|
+
componentY?: number;
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Where an image's blurhash lives: a tiny sidecar object in the same bucket.
|
|
2078
|
+
* Buckets have no per-object metadata channel (`put` is just `.put($content)`),
|
|
2079
|
+
* so the hash for `covers/x_t.webp` is the text object `covers/x_t.webp.bh`.
|
|
2080
|
+
*/
|
|
2081
|
+
declare function blurhashSidecarPath(path: string): string;
|
|
2082
|
+
/** Extensions `bucket.put` treats as images worth hashing. */
|
|
2083
|
+
declare const BLURHASH_IMAGE_EXTENSIONS: readonly ["webp", "png", "jpg", "jpeg", "gif", "avif", "bmp"];
|
|
2084
|
+
declare function isImagePath(path: string): boolean;
|
|
2085
|
+
/**
|
|
2086
|
+
* Decode `content` as an image and compute its blurhash. Browser-only: returns
|
|
2087
|
+
* null (never throws) when image decoding is unavailable (node, workers without
|
|
2088
|
+
* canvas), when the bytes are not a decodable image, or on any other failure —
|
|
2089
|
+
* a missing hash must never break the upload that triggered it.
|
|
2090
|
+
*/
|
|
2091
|
+
declare function encodeImageToBlurhash(content: string | Uint8Array | Blob, options?: BlurhashEncodeOptions): Promise<string | null>;
|
|
2092
|
+
//#endregion
|
|
2062
2093
|
//#region src/sp00ky.d.ts
|
|
2063
2094
|
/** Coerce whatever the `.get()` RPC hands back into a Blob. */
|
|
2064
2095
|
declare function bucketContentToBlob(content: unknown): Blob | null;
|
|
2096
|
+
interface BucketPutOptions {
|
|
2097
|
+
/** Override the client-level {@link Sp00kyConfig.blurhash} setting for this put. */
|
|
2098
|
+
blurhash?: BlurhashSetting;
|
|
2099
|
+
}
|
|
2100
|
+
interface BucketPutResult {
|
|
2101
|
+
/** The computed blurhash when the content was a hashable image; else null. */
|
|
2102
|
+
blurhash: string | null;
|
|
2103
|
+
}
|
|
2104
|
+
interface BucketHandleSettings {
|
|
2105
|
+
blurhash?: BlurhashSetting;
|
|
2106
|
+
logger?: {
|
|
2107
|
+
warn: (obj: unknown, msg?: string) => void;
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2065
2110
|
declare class BucketHandle {
|
|
2066
2111
|
private bucketName;
|
|
2067
2112
|
private remote;
|
|
2068
2113
|
/** Absent on the raw handle the cache itself reads through. */
|
|
2069
2114
|
private blobs?;
|
|
2115
|
+
private settings?;
|
|
2070
2116
|
constructor(bucketName: string, remote: RemoteDatabaseService, /** Absent on the raw handle the cache itself reads through. */
|
|
2071
|
-
blobs?: (BlobCache | null) | undefined);
|
|
2072
|
-
|
|
2117
|
+
blobs?: (BlobCache | null) | undefined, settings?: BucketHandleSettings | undefined);
|
|
2118
|
+
/** Effective blurhash setting: per-call option > client config > default ON. */
|
|
2119
|
+
private resolveBlurhash;
|
|
2120
|
+
put(path: string, content: string | Uint8Array | Blob, options?: BucketPutOptions): Promise<BucketPutResult>;
|
|
2121
|
+
/**
|
|
2122
|
+
* The blurhash stored alongside an uploaded image (see
|
|
2123
|
+
* {@link blurhashSidecarPath}), or null when there is none. Reads through the
|
|
2124
|
+
* blob cache, so a warm client answers from OPFS without a network hop, and
|
|
2125
|
+
* misses are remembered per tab so a hashless image costs at most one
|
|
2126
|
+
* serialized remote read per session.
|
|
2127
|
+
*/
|
|
2128
|
+
blurhash(path: string): Promise<string | null>;
|
|
2073
2129
|
get(path: string): Promise<unknown>;
|
|
2074
2130
|
/**
|
|
2075
2131
|
* Read through the local blob cache: OPFS first, the bucket second. Unlike
|
|
@@ -2333,4 +2389,4 @@ declare function textToHtml(text: string): string;
|
|
|
2333
2389
|
*/
|
|
2334
2390
|
|
|
2335
2391
|
//#endregion
|
|
2336
|
-
export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, BucketHandle, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
|
|
2392
|
+
export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, type BlurhashEncodeOptions, type BlurhashSetting, BucketHandle, BucketPutOptions, BucketPutResult, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, isBlurhashValid, isImagePath, semverGt, textToHtml };
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { QueryBuilder, RecordId as RecordId$1, cyrb53 } from "@spooky-sync/query
|
|
|
4
4
|
import pino from "pino";
|
|
5
5
|
import { applyPatch } from "fast-json-patch";
|
|
6
6
|
import init, { Sp00kyProcessor } from "@spooky-sync/ssp-wasm";
|
|
7
|
+
import { decode, encode, isBlurhashValid } from "blurhash";
|
|
7
8
|
|
|
8
9
|
//#region src/types.ts
|
|
9
10
|
/** Cap on the rolling materialization-sample window kept per query in memory. */
|
|
@@ -6746,7 +6747,18 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6746
6747
|
}, "Query to register not found");
|
|
6747
6748
|
throw new Error("Query to register not found");
|
|
6748
6749
|
}
|
|
6749
|
-
await this.remote.query("fn::query::heartbeat($id)", { id: queryState.config.id });
|
|
6750
|
+
const result = await this.remote.query("fn::query::heartbeat($id)", { id: queryState.config.id });
|
|
6751
|
+
const updated = Array.isArray(result) ? result[0] : void 0;
|
|
6752
|
+
if (!(Array.isArray(updated) && updated.length === 0)) return;
|
|
6753
|
+
this.logger.warn({
|
|
6754
|
+
queryHash,
|
|
6755
|
+
id: String(queryState.config.id),
|
|
6756
|
+
Category: "sp00ky-client::Sp00kySync::heartbeatQuery"
|
|
6757
|
+
}, "Query row was reclaimed while still in use; re-registering");
|
|
6758
|
+
this.enqueueDownEvent({
|
|
6759
|
+
type: "register",
|
|
6760
|
+
payload: { hash: queryHash }
|
|
6761
|
+
});
|
|
6750
6762
|
}
|
|
6751
6763
|
async cleanupQuery(queryHash) {
|
|
6752
6764
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
@@ -7081,8 +7093,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7081
7093
|
|
|
7082
7094
|
//#endregion
|
|
7083
7095
|
//#region src/modules/devtools/index.ts
|
|
7084
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7085
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7096
|
+
const CORE_VERSION = "0.0.1-canary.199";
|
|
7097
|
+
const WASM_VERSION = "0.0.1-canary.199";
|
|
7086
7098
|
const SURREAL_VERSION = "3.0.3";
|
|
7087
7099
|
var DevToolsService = class DevToolsService {
|
|
7088
7100
|
eventsHistory = [];
|
|
@@ -11211,6 +11223,65 @@ function createBlobCache(opts) {
|
|
|
11211
11223
|
});
|
|
11212
11224
|
}
|
|
11213
11225
|
|
|
11226
|
+
//#endregion
|
|
11227
|
+
//#region src/utils/blurhash.ts
|
|
11228
|
+
/**
|
|
11229
|
+
* Where an image's blurhash lives: a tiny sidecar object in the same bucket.
|
|
11230
|
+
* Buckets have no per-object metadata channel (`put` is just `.put($content)`),
|
|
11231
|
+
* so the hash for `covers/x_t.webp` is the text object `covers/x_t.webp.bh`.
|
|
11232
|
+
*/
|
|
11233
|
+
function blurhashSidecarPath(path) {
|
|
11234
|
+
return `${path}.bh`;
|
|
11235
|
+
}
|
|
11236
|
+
/** Extensions `bucket.put` treats as images worth hashing. */
|
|
11237
|
+
const BLURHASH_IMAGE_EXTENSIONS = [
|
|
11238
|
+
"webp",
|
|
11239
|
+
"png",
|
|
11240
|
+
"jpg",
|
|
11241
|
+
"jpeg",
|
|
11242
|
+
"gif",
|
|
11243
|
+
"avif",
|
|
11244
|
+
"bmp"
|
|
11245
|
+
];
|
|
11246
|
+
const IMAGE_EXT_RE = new RegExp(`\\.(${BLURHASH_IMAGE_EXTENSIONS.join("|")})$`, "i");
|
|
11247
|
+
function isImagePath(path) {
|
|
11248
|
+
return IMAGE_EXT_RE.test(path);
|
|
11249
|
+
}
|
|
11250
|
+
/** Longest edge of the downscale the hash is computed from. Blurhash carries at
|
|
11251
|
+
* most 9x9 DCT components, so anything past ~32px is wasted decode work. */
|
|
11252
|
+
const ENCODE_MAX_EDGE = 32;
|
|
11253
|
+
/**
|
|
11254
|
+
* Decode `content` as an image and compute its blurhash. Browser-only: returns
|
|
11255
|
+
* null (never throws) when image decoding is unavailable (node, workers without
|
|
11256
|
+
* canvas), when the bytes are not a decodable image, or on any other failure —
|
|
11257
|
+
* a missing hash must never break the upload that triggered it.
|
|
11258
|
+
*/
|
|
11259
|
+
async function encodeImageToBlurhash(content, options) {
|
|
11260
|
+
if (typeof createImageBitmap !== "function") return null;
|
|
11261
|
+
let bitmap = null;
|
|
11262
|
+
try {
|
|
11263
|
+
const blob = content instanceof Blob ? content : new Blob([content]);
|
|
11264
|
+
bitmap = await createImageBitmap(blob);
|
|
11265
|
+
const scale = Math.min(1, ENCODE_MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
|
11266
|
+
const width = Math.max(1, Math.round(bitmap.width * scale));
|
|
11267
|
+
const height = Math.max(1, Math.round(bitmap.height * scale));
|
|
11268
|
+
const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(width, height) : typeof document !== "undefined" ? Object.assign(document.createElement("canvas"), {
|
|
11269
|
+
width,
|
|
11270
|
+
height
|
|
11271
|
+
}) : null;
|
|
11272
|
+
if (!canvas) return null;
|
|
11273
|
+
const ctx = canvas.getContext("2d");
|
|
11274
|
+
if (!ctx) return null;
|
|
11275
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
11276
|
+
const { data } = ctx.getImageData(0, 0, width, height);
|
|
11277
|
+
return encode(data, width, height, options?.componentX ?? 4, options?.componentY ?? 3);
|
|
11278
|
+
} catch {
|
|
11279
|
+
return null;
|
|
11280
|
+
} finally {
|
|
11281
|
+
bitmap?.close();
|
|
11282
|
+
}
|
|
11283
|
+
}
|
|
11284
|
+
|
|
11214
11285
|
//#endregion
|
|
11215
11286
|
//#region src/sp00ky.ts
|
|
11216
11287
|
/** Coerce whatever the `.get()` RPC hands back into a Blob. */
|
|
@@ -11222,18 +11293,85 @@ function bucketContentToBlob(content) {
|
|
|
11222
11293
|
if (ArrayBuffer.isView(content)) return new Blob([content]);
|
|
11223
11294
|
return null;
|
|
11224
11295
|
}
|
|
11296
|
+
/**
|
|
11297
|
+
* Paths known to have no blurhash sidecar, per tab. The blob cache has no
|
|
11298
|
+
* negative caching, so without this every mount of a hashless image would pay
|
|
11299
|
+
* one serialized remote read. Cleared when a put writes a sidecar or a delete
|
|
11300
|
+
* removes the image. Keyed `${bucket}:${path}` (the image path, not the sidecar).
|
|
11301
|
+
*/
|
|
11302
|
+
const missingBlurhash = /* @__PURE__ */ new Set();
|
|
11225
11303
|
var BucketHandle = class {
|
|
11226
|
-
constructor(bucketName, remote, blobs) {
|
|
11304
|
+
constructor(bucketName, remote, blobs, settings) {
|
|
11227
11305
|
this.bucketName = bucketName;
|
|
11228
11306
|
this.remote = remote;
|
|
11229
11307
|
this.blobs = blobs;
|
|
11308
|
+
this.settings = settings;
|
|
11309
|
+
}
|
|
11310
|
+
/** Effective blurhash setting: per-call option > client config > default ON. */
|
|
11311
|
+
resolveBlurhash(option) {
|
|
11312
|
+
const setting = option ?? this.settings?.blurhash ?? true;
|
|
11313
|
+
if (setting === false) return null;
|
|
11314
|
+
return setting === true ? {} : setting;
|
|
11230
11315
|
}
|
|
11231
|
-
async put(path, content) {
|
|
11316
|
+
async put(path, content, options) {
|
|
11317
|
+
const encodeOptions = this.resolveBlurhash(options?.blurhash);
|
|
11318
|
+
const hashPromise = encodeOptions && isImagePath(path) ? encodeImageToBlurhash(content, encodeOptions) : Promise.resolve(null);
|
|
11232
11319
|
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
|
|
11233
11320
|
await this.blobs?.invalidate({
|
|
11234
11321
|
bucket: this.bucketName,
|
|
11235
11322
|
path
|
|
11236
11323
|
});
|
|
11324
|
+
let hash = null;
|
|
11325
|
+
try {
|
|
11326
|
+
hash = await hashPromise;
|
|
11327
|
+
if (hash) {
|
|
11328
|
+
const sidecar = blurhashSidecarPath(path);
|
|
11329
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".put($content);`, { content: hash });
|
|
11330
|
+
await this.blobs?.invalidate({
|
|
11331
|
+
bucket: this.bucketName,
|
|
11332
|
+
path: sidecar
|
|
11333
|
+
});
|
|
11334
|
+
missingBlurhash.delete(`${this.bucketName}:${path}`);
|
|
11335
|
+
}
|
|
11336
|
+
} catch (error) {
|
|
11337
|
+
hash = null;
|
|
11338
|
+
this.settings?.logger?.warn({
|
|
11339
|
+
error,
|
|
11340
|
+
path
|
|
11341
|
+
}, "blurhash sidecar put failed");
|
|
11342
|
+
}
|
|
11343
|
+
return { blurhash: hash };
|
|
11344
|
+
}
|
|
11345
|
+
/**
|
|
11346
|
+
* The blurhash stored alongside an uploaded image (see
|
|
11347
|
+
* {@link blurhashSidecarPath}), or null when there is none. Reads through the
|
|
11348
|
+
* blob cache, so a warm client answers from OPFS without a network hop, and
|
|
11349
|
+
* misses are remembered per tab so a hashless image costs at most one
|
|
11350
|
+
* serialized remote read per session.
|
|
11351
|
+
*/
|
|
11352
|
+
async blurhash(path) {
|
|
11353
|
+
const cacheKey = `${this.bucketName}:${path}`;
|
|
11354
|
+
if (missingBlurhash.has(cacheKey)) return null;
|
|
11355
|
+
try {
|
|
11356
|
+
const blob = await this.read(blurhashSidecarPath(path), { persist: true });
|
|
11357
|
+
if (!blob) {
|
|
11358
|
+
missingBlurhash.add(cacheKey);
|
|
11359
|
+
return null;
|
|
11360
|
+
}
|
|
11361
|
+
const hash = (await blob.text()).trim();
|
|
11362
|
+
if (!isBlurhashValid(hash).result) {
|
|
11363
|
+
this.settings?.logger?.warn({ path }, "blurhash sidecar holds an invalid hash");
|
|
11364
|
+
missingBlurhash.add(cacheKey);
|
|
11365
|
+
return null;
|
|
11366
|
+
}
|
|
11367
|
+
return hash;
|
|
11368
|
+
} catch (error) {
|
|
11369
|
+
this.settings?.logger?.warn({
|
|
11370
|
+
error,
|
|
11371
|
+
path
|
|
11372
|
+
}, "blurhash sidecar read failed");
|
|
11373
|
+
return null;
|
|
11374
|
+
}
|
|
11237
11375
|
}
|
|
11238
11376
|
async get(path) {
|
|
11239
11377
|
const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".get();`);
|
|
@@ -11296,6 +11434,17 @@ var BucketHandle = class {
|
|
|
11296
11434
|
bucket: this.bucketName,
|
|
11297
11435
|
path
|
|
11298
11436
|
});
|
|
11437
|
+
if (isImagePath(path)) {
|
|
11438
|
+
const sidecar = blurhashSidecarPath(path);
|
|
11439
|
+
try {
|
|
11440
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".delete();`);
|
|
11441
|
+
await this.blobs?.invalidate({
|
|
11442
|
+
bucket: this.bucketName,
|
|
11443
|
+
path: sidecar
|
|
11444
|
+
});
|
|
11445
|
+
} catch {}
|
|
11446
|
+
missingBlurhash.delete(`${this.bucketName}:${path}`);
|
|
11447
|
+
}
|
|
11299
11448
|
}
|
|
11300
11449
|
async exists(path) {
|
|
11301
11450
|
const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".exists();`);
|
|
@@ -11513,7 +11662,7 @@ var Sp00kyClient = class {
|
|
|
11513
11662
|
return new TabsCoordinator({
|
|
11514
11663
|
tabId,
|
|
11515
11664
|
fingerprint: computeTabsFingerprint({
|
|
11516
|
-
coreVersion: "0.0.1-canary.
|
|
11665
|
+
coreVersion: "0.0.1-canary.199",
|
|
11517
11666
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11518
11667
|
endpoint: this.config.database.endpoint ?? "",
|
|
11519
11668
|
namespace: this.config.database.namespace,
|
|
@@ -12063,12 +12212,18 @@ var Sp00kyClient = class {
|
|
|
12063
12212
|
return this.dataModule.run(backend, path, payload, options);
|
|
12064
12213
|
}
|
|
12065
12214
|
bucket(name) {
|
|
12066
|
-
return new BucketHandle(name, this.remote, this.blobs
|
|
12215
|
+
return new BucketHandle(name, this.remote, this.blobs, {
|
|
12216
|
+
blurhash: this.config.blurhash,
|
|
12217
|
+
logger: this.logger
|
|
12218
|
+
});
|
|
12067
12219
|
}
|
|
12068
12220
|
/** Cache-free handle. The blob cache reads the remote through this, so a
|
|
12069
12221
|
* cache miss can't loop back into the cache. */
|
|
12070
12222
|
rawBucket(name) {
|
|
12071
|
-
return new BucketHandle(name, this.remote, null
|
|
12223
|
+
return new BucketHandle(name, this.remote, null, {
|
|
12224
|
+
blurhash: this.config.blurhash,
|
|
12225
|
+
logger: this.logger
|
|
12226
|
+
});
|
|
12072
12227
|
}
|
|
12073
12228
|
/** Blob cache counters for DevTools. */
|
|
12074
12229
|
getBlobCacheStats() {
|
|
@@ -12107,4 +12262,4 @@ var Sp00kyClient = class {
|
|
|
12107
12262
|
};
|
|
12108
12263
|
|
|
12109
12264
|
//#endregion
|
|
12110
|
-
export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
|
|
12265
|
+
export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, isBlurhashValid, isImagePath, semverGt, textToHtml };
|
package/dist/types.d.ts
CHANGED
|
@@ -629,6 +629,22 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
629
629
|
* (or `degradeAfterConsecutiveFailures: 0`) to never report degraded.
|
|
630
630
|
*/
|
|
631
631
|
syncHealth?: SyncHealthConfig | false;
|
|
632
|
+
/**
|
|
633
|
+
* Automatic blurhash placeholders for bucket image uploads. On every
|
|
634
|
+
* `bucket.put` of an image path (by extension: webp/png/jpg/jpeg/gif/avif/bmp)
|
|
635
|
+
* the client computes a blurhash and stores it as a tiny sidecar object
|
|
636
|
+
* `<path>.bh` in the same bucket, best-effort. Read it back with
|
|
637
|
+
* `bucket.blurhash(path)` (or the client-solid `useBucketImage`/`BucketImage`
|
|
638
|
+
* helpers) to paint a placeholder until the image is decoded.
|
|
639
|
+
*
|
|
640
|
+
* `true` (the default) enables with 4x3 components; pass
|
|
641
|
+
* `{ componentX, componentY }` to tune detail, or `false` to disable.
|
|
642
|
+
* A per-call `put(path, content, { blurhash })` option overrides this.
|
|
643
|
+
*/
|
|
644
|
+
blurhash?: boolean | {
|
|
645
|
+
componentX?: number;
|
|
646
|
+
componentY?: number;
|
|
647
|
+
};
|
|
632
648
|
/**
|
|
633
649
|
* Deadline (ms) for a single outgoing mutation push. Tighter than
|
|
634
650
|
* {@link Sp00kyConfig.database.queryTimeoutMs} because the up-queue drains
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.199",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -60,10 +60,11 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
63
|
+
"@spooky-sync/query-builder": "0.0.1-canary.199",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.199",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
|
+
"blurhash": "^2.0.5",
|
|
67
68
|
"fast-json-patch": "^3.1.1",
|
|
68
69
|
"loro-crdt": "^1.5.6",
|
|
69
70
|
"pino": "^10.1.0",
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { BucketHandle } from './sp00ky';
|
|
3
|
+
import type { RemoteDatabaseService } from './services/database/index';
|
|
4
|
+
import {
|
|
5
|
+
blurhashSidecarPath,
|
|
6
|
+
isImagePath,
|
|
7
|
+
encodeImageToBlurhash,
|
|
8
|
+
encodeBlurhash,
|
|
9
|
+
} from './utils/blurhash';
|
|
10
|
+
|
|
11
|
+
// A real 1x1 hash so isBlurhashValid passes in the read-path tests.
|
|
12
|
+
const VALID_HASH = encodeBlurhash(new Uint8ClampedArray([12, 34, 56, 255]), 1, 1, 1, 1);
|
|
13
|
+
|
|
14
|
+
function makeRemote(queries: string[]) {
|
|
15
|
+
return {
|
|
16
|
+
query: vi.fn(async (surql: string) => {
|
|
17
|
+
queries.push(surql);
|
|
18
|
+
return [null];
|
|
19
|
+
}),
|
|
20
|
+
} as unknown as RemoteDatabaseService;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('blurhash path helpers', () => {
|
|
24
|
+
it('appends .bh for the sidecar', () => {
|
|
25
|
+
expect(blurhashSidecarPath('a/b_t.webp')).toBe('a/b_t.webp.bh');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('recognizes image extensions case-insensitively', () => {
|
|
29
|
+
expect(isImagePath('x/y.webp')).toBe(true);
|
|
30
|
+
expect(isImagePath('x/y.PNG')).toBe(true);
|
|
31
|
+
expect(isImagePath('x/y.webp.bh')).toBe(false);
|
|
32
|
+
expect(isImagePath('x/notes.txt')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('encodeImageToBlurhash outside a browser', () => {
|
|
37
|
+
it('returns null instead of throwing (no createImageBitmap in node)', async () => {
|
|
38
|
+
await expect(encodeImageToBlurhash(new Uint8Array([1, 2, 3]))).resolves.toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('BucketHandle.put', () => {
|
|
43
|
+
let queries: string[];
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
queries = [];
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('image put succeeds with blurhash null when encoding is unavailable', async () => {
|
|
50
|
+
const handle = new BucketHandle('covers', makeRemote(queries), null);
|
|
51
|
+
const result = await handle.put('a/b_t.webp', new Uint8Array([1]));
|
|
52
|
+
expect(result).toEqual({ blurhash: null });
|
|
53
|
+
// Only the image put went out; no sidecar for a failed/unavailable encode.
|
|
54
|
+
expect(queries).toHaveLength(1);
|
|
55
|
+
expect(queries[0]).toContain('covers:/a/b_t.webp".put');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('skips hashing entirely for non-image paths and when disabled', async () => {
|
|
59
|
+
const handle = new BucketHandle('covers', makeRemote(queries), null, { blurhash: false });
|
|
60
|
+
await handle.put('a/b_t.webp', new Uint8Array([1]));
|
|
61
|
+
await handle.put('a/readme.txt', 'hello');
|
|
62
|
+
expect(queries).toHaveLength(2);
|
|
63
|
+
expect(queries.some((q) => q.includes('.bh'))).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('writes the sidecar when a hash is produced', async () => {
|
|
67
|
+
// Simulate a browser: a createImageBitmap whose bitmap a canvas can read.
|
|
68
|
+
const pixels = { width: 2, height: 2 };
|
|
69
|
+
vi.stubGlobal(
|
|
70
|
+
'createImageBitmap',
|
|
71
|
+
vi.fn(async () => ({ ...pixels, close: vi.fn() }))
|
|
72
|
+
);
|
|
73
|
+
vi.stubGlobal(
|
|
74
|
+
'OffscreenCanvas',
|
|
75
|
+
class {
|
|
76
|
+
width: number;
|
|
77
|
+
height: number;
|
|
78
|
+
constructor(w: number, h: number) {
|
|
79
|
+
this.width = w;
|
|
80
|
+
this.height = h;
|
|
81
|
+
}
|
|
82
|
+
getContext() {
|
|
83
|
+
return {
|
|
84
|
+
drawImage: () => {},
|
|
85
|
+
getImageData: (_x: number, _y: number, w: number, h: number) => ({
|
|
86
|
+
data: new Uint8ClampedArray(w * h * 4).fill(128),
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
try {
|
|
93
|
+
const handle = new BucketHandle('covers', makeRemote(queries), null);
|
|
94
|
+
const result = await handle.put('a/b_t.webp', new Uint8Array([1]));
|
|
95
|
+
expect(result.blurhash).toBeTruthy();
|
|
96
|
+
expect(queries).toHaveLength(2);
|
|
97
|
+
expect(queries[1]).toContain('covers:/a/b_t.webp.bh".put');
|
|
98
|
+
} finally {
|
|
99
|
+
vi.unstubAllGlobals();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('BucketHandle.blurhash', () => {
|
|
105
|
+
it('returns a valid stored hash and negative-caches misses per path', async () => {
|
|
106
|
+
const reads: string[] = [];
|
|
107
|
+
let payload: unknown = VALID_HASH;
|
|
108
|
+
const remote = {
|
|
109
|
+
query: vi.fn(async (surql: string) => {
|
|
110
|
+
reads.push(surql);
|
|
111
|
+
return [payload];
|
|
112
|
+
}),
|
|
113
|
+
} as unknown as RemoteDatabaseService;
|
|
114
|
+
const handle = new BucketHandle('covers', remote, null);
|
|
115
|
+
|
|
116
|
+
await expect(handle.blurhash('hit/x.webp')).resolves.toBe(VALID_HASH);
|
|
117
|
+
|
|
118
|
+
payload = null;
|
|
119
|
+
await expect(handle.blurhash('miss/x.webp')).resolves.toBeNull();
|
|
120
|
+
const readsAfterMiss = reads.length;
|
|
121
|
+
// The miss is remembered: no second remote read for the same path.
|
|
122
|
+
await expect(handle.blurhash('miss/x.webp')).resolves.toBeNull();
|
|
123
|
+
expect(reads).toHaveLength(readsAfterMiss);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('treats invalid sidecar content as missing', async () => {
|
|
127
|
+
const remote = {
|
|
128
|
+
query: vi.fn(async () => ['not a blurhash \\ at all']),
|
|
129
|
+
} as unknown as RemoteDatabaseService;
|
|
130
|
+
const handle = new BucketHandle('covers', remote, null);
|
|
131
|
+
await expect(handle.blurhash('bad/x.webp')).resolves.toBeNull();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('BucketHandle.delete', () => {
|
|
136
|
+
it('also deletes the sidecar for image paths, best-effort', async () => {
|
|
137
|
+
const queries: string[] = [];
|
|
138
|
+
const handle = new BucketHandle('covers', makeRemote(queries), null);
|
|
139
|
+
await handle.delete('a/b_t.webp');
|
|
140
|
+
expect(queries).toHaveLength(2);
|
|
141
|
+
expect(queries[0]).toContain('covers:/a/b_t.webp".delete');
|
|
142
|
+
expect(queries[1]).toContain('covers:/a/b_t.webp.bh".delete');
|
|
143
|
+
|
|
144
|
+
queries.length = 0;
|
|
145
|
+
await handle.delete('a/notes.txt');
|
|
146
|
+
expect(queries).toHaveLength(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -24,3 +24,14 @@ export type {
|
|
|
24
24
|
} from './services/blobs/index';
|
|
25
25
|
export { semverGt } from './utils/semver';
|
|
26
26
|
export { fileToUint8Array, textToHtml } from './utils/index';
|
|
27
|
+
export {
|
|
28
|
+
blurhashSidecarPath,
|
|
29
|
+
encodeImageToBlurhash,
|
|
30
|
+
encodeBlurhash,
|
|
31
|
+
decodeBlurhash,
|
|
32
|
+
isBlurhashValid,
|
|
33
|
+
isImagePath,
|
|
34
|
+
BLURHASH_IMAGE_EXTENSIONS,
|
|
35
|
+
type BlurhashSetting,
|
|
36
|
+
type BlurhashEncodeOptions,
|
|
37
|
+
} from './utils/blurhash';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { Sp00kySync } from './sync';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `fn::query::heartbeat` is an `UPDATE $id SET ...`. Against a record that no
|
|
7
|
+
* longer exists it matches nothing and returns an empty array — it does NOT
|
|
8
|
+
* recreate the row. Verified against the deployed function:
|
|
9
|
+
*
|
|
10
|
+
* RETURN fn::query::heartbeat(_00_query:definitely_not_a_real_row_xyz);
|
|
11
|
+
* -- (0 rows)
|
|
12
|
+
*
|
|
13
|
+
* So an unchecked heartbeat cannot tell "refreshed" from "the row I am
|
|
14
|
+
* refreshing is gone", and a client whose row was reclaimed by the TTL sweep
|
|
15
|
+
* beats against nothing forever: no membership, no edges, no re-registration.
|
|
16
|
+
* The page then renders as though the data had been deleted — reported as
|
|
17
|
+
* "Game not found" on a game that was open and working.
|
|
18
|
+
*
|
|
19
|
+
* This is reachable in ordinary use: the sweep expires on `lastActiveAt + ttl`
|
|
20
|
+
* while the heartbeat runs on a timer browsers throttle hard in background
|
|
21
|
+
* tabs, so a second window left idle past its TTL is the normal way in.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function makeSync(heartbeatResult: unknown) {
|
|
25
|
+
const logger: any = {
|
|
26
|
+
child: () => logger,
|
|
27
|
+
debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {},
|
|
28
|
+
};
|
|
29
|
+
const remote: any = { query: vi.fn().mockResolvedValue(heartbeatResult) };
|
|
30
|
+
const queryState: any = { config: { id: new RecordId('_00_query', 'h1') } };
|
|
31
|
+
const dataModule: any = { getQueryByHash: vi.fn().mockReturnValue(queryState) };
|
|
32
|
+
|
|
33
|
+
const sync = new Sp00kySync({} as any, remote, {} as any, dataModule, {} as any, logger);
|
|
34
|
+
const enqueueDownEvent = vi.fn();
|
|
35
|
+
(sync as any).enqueueDownEvent = enqueueDownEvent;
|
|
36
|
+
|
|
37
|
+
return { sync, remote, dataModule, enqueueDownEvent };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('heartbeatQuery — noticing a reclaimed row', () => {
|
|
41
|
+
beforeEach(() => vi.clearAllMocks());
|
|
42
|
+
|
|
43
|
+
it('re-registers when the row it beat against is gone', async () => {
|
|
44
|
+
// `UPDATE` on a deleted record: one statement, zero updated records.
|
|
45
|
+
const { sync, enqueueDownEvent } = makeSync([[]]);
|
|
46
|
+
|
|
47
|
+
await sync.heartbeatQuery('h1');
|
|
48
|
+
|
|
49
|
+
expect(enqueueDownEvent).toHaveBeenCalledWith({
|
|
50
|
+
type: 'register',
|
|
51
|
+
payload: { hash: 'h1' },
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('does nothing extra on a healthy heartbeat', async () => {
|
|
56
|
+
const { sync, enqueueDownEvent } = makeSync([[{ id: 'x', lastActiveAt: 'now' }]]);
|
|
57
|
+
|
|
58
|
+
await sync.heartbeatQuery('h1');
|
|
59
|
+
|
|
60
|
+
expect(enqueueDownEvent).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not re-register on an unrecognised result shape', async () => {
|
|
64
|
+
// Only an explicitly EMPTY update result means "the row is gone". Anything
|
|
65
|
+
// else — a driver returning null, a shape change — must not be read as
|
|
66
|
+
// deletion, or every heartbeat would re-register the whole working set.
|
|
67
|
+
for (const shape of [null, undefined, [], [null], ['unexpected']]) {
|
|
68
|
+
const { sync, enqueueDownEvent } = makeSync(shape);
|
|
69
|
+
await sync.heartbeatQuery('h1');
|
|
70
|
+
expect(enqueueDownEvent, `shape ${JSON.stringify(shape)}`).not.toHaveBeenCalled();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('still throws for a query that is no longer registered locally', async () => {
|
|
75
|
+
const { sync, dataModule } = makeSync([[]]);
|
|
76
|
+
dataModule.getQueryByHash.mockReturnValue(undefined);
|
|
77
|
+
|
|
78
|
+
await expect(sync.heartbeatQuery('gone')).rejects.toThrow();
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -1768,9 +1768,40 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1768
1768
|
);
|
|
1769
1769
|
throw new Error('Query to register not found');
|
|
1770
1770
|
}
|
|
1771
|
-
|
|
1771
|
+
// `fn::query::heartbeat` is an `UPDATE $id SET ...`. On a record that no
|
|
1772
|
+
// longer exists that matches nothing and returns an empty array — it does
|
|
1773
|
+
// NOT recreate the row. So an unchecked heartbeat is indistinguishable from
|
|
1774
|
+
// a successful one, and a client whose row was reclaimed keeps beating
|
|
1775
|
+
// against nothing forever: no membership, no edges, no re-registration.
|
|
1776
|
+
// The page renders as if the data were deleted ("Game not found").
|
|
1777
|
+
//
|
|
1778
|
+
// A live query's row is reclaimed more easily than it looks. The sweep
|
|
1779
|
+
// expires on `lastActiveAt + ttl`, and this heartbeat runs on a timer that
|
|
1780
|
+
// browsers throttle hard in background tabs — so a second window left idle
|
|
1781
|
+
// past its TTL is the ordinary way to get here, not an edge case. Until
|
|
1782
|
+
// canary.194 the sweep could not actually remove the in-memory view (it
|
|
1783
|
+
// looked it up under the other of the two query-id spellings), which masked
|
|
1784
|
+
// this: the view survived its own row. Now reclamation is real, so the
|
|
1785
|
+
// client has to notice and rebuild.
|
|
1786
|
+
const result = await this.remote.query('fn::query::heartbeat($id)', {
|
|
1772
1787
|
id: queryState.config.id,
|
|
1773
1788
|
});
|
|
1789
|
+
const updated = Array.isArray(result) ? result[0] : undefined;
|
|
1790
|
+
const rowGone = Array.isArray(updated) && updated.length === 0;
|
|
1791
|
+
if (!rowGone) return;
|
|
1792
|
+
|
|
1793
|
+
this.logger.warn(
|
|
1794
|
+
{
|
|
1795
|
+
queryHash,
|
|
1796
|
+
id: String(queryState.config.id),
|
|
1797
|
+
Category: 'sp00ky-client::Sp00kySync::heartbeatQuery',
|
|
1798
|
+
},
|
|
1799
|
+
'Query row was reclaimed while still in use; re-registering'
|
|
1800
|
+
);
|
|
1801
|
+
// Re-register rather than recreate the row here: the row alone is useless
|
|
1802
|
+
// without the SSP view behind it, and only registration rebuilds the view,
|
|
1803
|
+
// republishes `_00_list_ref` and writes `rowCount`.
|
|
1804
|
+
this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
|
|
1774
1805
|
}
|
|
1775
1806
|
|
|
1776
1807
|
// Eager teardown of a deregistered query's remote `_00_query` view (opt-in,
|
package/src/sp00ky.ts
CHANGED
|
@@ -62,6 +62,15 @@ import type { SqliteCacheEngine } from './services/database/sqlite-cache-engine'
|
|
|
62
62
|
import type { BlobCache, BlobReadOptions, BlobUrlLease } from './services/blobs/index';
|
|
63
63
|
import { MemoryBlobStore, createBlobCache, resolveBlobBudget } from './services/blobs/index';
|
|
64
64
|
|
|
65
|
+
import {
|
|
66
|
+
blurhashSidecarPath,
|
|
67
|
+
encodeImageToBlurhash,
|
|
68
|
+
isImagePath,
|
|
69
|
+
isBlurhashValid,
|
|
70
|
+
type BlurhashSetting,
|
|
71
|
+
type BlurhashEncodeOptions,
|
|
72
|
+
} from './utils/blurhash';
|
|
73
|
+
|
|
65
74
|
/** Coerce whatever the `.get()` RPC hands back into a Blob. */
|
|
66
75
|
export function bucketContentToBlob(content: unknown): Blob | null {
|
|
67
76
|
if (content == null) return null;
|
|
@@ -74,18 +83,110 @@ export function bucketContentToBlob(content: unknown): Blob | null {
|
|
|
74
83
|
return null;
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
export interface BucketPutOptions {
|
|
87
|
+
/** Override the client-level {@link Sp00kyConfig.blurhash} setting for this put. */
|
|
88
|
+
blurhash?: BlurhashSetting;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface BucketPutResult {
|
|
92
|
+
/** The computed blurhash when the content was a hashable image; else null. */
|
|
93
|
+
blurhash: string | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface BucketHandleSettings {
|
|
97
|
+
blurhash?: BlurhashSetting;
|
|
98
|
+
logger?: { warn: (obj: unknown, msg?: string) => void };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Paths known to have no blurhash sidecar, per tab. The blob cache has no
|
|
103
|
+
* negative caching, so without this every mount of a hashless image would pay
|
|
104
|
+
* one serialized remote read. Cleared when a put writes a sidecar or a delete
|
|
105
|
+
* removes the image. Keyed `${bucket}:${path}` (the image path, not the sidecar).
|
|
106
|
+
*/
|
|
107
|
+
const missingBlurhash = new Set<string>();
|
|
108
|
+
|
|
77
109
|
export class BucketHandle {
|
|
78
110
|
constructor(
|
|
79
111
|
private bucketName: string,
|
|
80
112
|
private remote: RemoteDatabaseService,
|
|
81
113
|
/** Absent on the raw handle the cache itself reads through. */
|
|
82
|
-
private blobs?: BlobCache | null
|
|
114
|
+
private blobs?: BlobCache | null,
|
|
115
|
+
private settings?: BucketHandleSettings
|
|
83
116
|
) {}
|
|
84
117
|
|
|
85
|
-
|
|
118
|
+
/** Effective blurhash setting: per-call option > client config > default ON. */
|
|
119
|
+
private resolveBlurhash(option?: BlurhashSetting): BlurhashEncodeOptions | null {
|
|
120
|
+
const setting = option ?? this.settings?.blurhash ?? true;
|
|
121
|
+
if (setting === false) return null;
|
|
122
|
+
return setting === true ? {} : setting;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async put(
|
|
126
|
+
path: string,
|
|
127
|
+
content: string | Uint8Array | Blob,
|
|
128
|
+
options?: BucketPutOptions
|
|
129
|
+
): Promise<BucketPutResult> {
|
|
130
|
+
// Start hashing while the upload is in flight; both browser-side costs
|
|
131
|
+
// overlap and the sidecar put only queues once the main put resolved.
|
|
132
|
+
const encodeOptions = this.resolveBlurhash(options?.blurhash);
|
|
133
|
+
const hashPromise =
|
|
134
|
+
encodeOptions && isImagePath(path)
|
|
135
|
+
? encodeImageToBlurhash(content, encodeOptions)
|
|
136
|
+
: Promise.resolve(null);
|
|
137
|
+
|
|
86
138
|
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
|
|
87
139
|
// A path can be overwritten, so anything cached under it is now wrong.
|
|
88
140
|
await this.blobs?.invalidate({ bucket: this.bucketName, path });
|
|
141
|
+
|
|
142
|
+
// The sidecar is best-effort: a hash or sidecar failure must never fail
|
|
143
|
+
// the image put that triggered it.
|
|
144
|
+
let hash: string | null = null;
|
|
145
|
+
try {
|
|
146
|
+
hash = await hashPromise;
|
|
147
|
+
if (hash) {
|
|
148
|
+
const sidecar = blurhashSidecarPath(path);
|
|
149
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".put($content);`, {
|
|
150
|
+
content: hash,
|
|
151
|
+
});
|
|
152
|
+
await this.blobs?.invalidate({ bucket: this.bucketName, path: sidecar });
|
|
153
|
+
missingBlurhash.delete(`${this.bucketName}:${path}`);
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
hash = null;
|
|
157
|
+
this.settings?.logger?.warn({ error, path }, 'blurhash sidecar put failed');
|
|
158
|
+
}
|
|
159
|
+
return { blurhash: hash };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The blurhash stored alongside an uploaded image (see
|
|
164
|
+
* {@link blurhashSidecarPath}), or null when there is none. Reads through the
|
|
165
|
+
* blob cache, so a warm client answers from OPFS without a network hop, and
|
|
166
|
+
* misses are remembered per tab so a hashless image costs at most one
|
|
167
|
+
* serialized remote read per session.
|
|
168
|
+
*/
|
|
169
|
+
async blurhash(path: string): Promise<string | null> {
|
|
170
|
+
const cacheKey = `${this.bucketName}:${path}`;
|
|
171
|
+
if (missingBlurhash.has(cacheKey)) return null;
|
|
172
|
+
try {
|
|
173
|
+
const blob = await this.read(blurhashSidecarPath(path), { persist: true });
|
|
174
|
+
if (!blob) {
|
|
175
|
+
missingBlurhash.add(cacheKey);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const hash = (await blob.text()).trim();
|
|
179
|
+
if (!isBlurhashValid(hash).result) {
|
|
180
|
+
this.settings?.logger?.warn({ path }, 'blurhash sidecar holds an invalid hash');
|
|
181
|
+
missingBlurhash.add(cacheKey);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return hash;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// Transient failure: do NOT negative-cache, the next mount may succeed.
|
|
187
|
+
this.settings?.logger?.warn({ error, path }, 'blurhash sidecar read failed');
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
89
190
|
}
|
|
90
191
|
|
|
91
192
|
async get(path: string): Promise<unknown> {
|
|
@@ -137,6 +238,18 @@ export class BucketHandle {
|
|
|
137
238
|
async delete(path: string): Promise<void> {
|
|
138
239
|
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
|
|
139
240
|
await this.blobs?.invalidate({ bucket: this.bucketName, path });
|
|
241
|
+
// Symmetry with put: an image's blurhash sidecar dies with it. Best-effort,
|
|
242
|
+
// the sidecar may simply not exist.
|
|
243
|
+
if (isImagePath(path)) {
|
|
244
|
+
const sidecar = blurhashSidecarPath(path);
|
|
245
|
+
try {
|
|
246
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".delete();`);
|
|
247
|
+
await this.blobs?.invalidate({ bucket: this.bucketName, path: sidecar });
|
|
248
|
+
} catch {
|
|
249
|
+
// Nothing to clean up.
|
|
250
|
+
}
|
|
251
|
+
missingBlurhash.delete(`${this.bucketName}:${path}`);
|
|
252
|
+
}
|
|
140
253
|
}
|
|
141
254
|
|
|
142
255
|
async exists(path: string): Promise<boolean> {
|
|
@@ -1373,13 +1486,19 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1373
1486
|
}
|
|
1374
1487
|
|
|
1375
1488
|
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
1376
|
-
return new BucketHandle(name, this.remote, this.blobs
|
|
1489
|
+
return new BucketHandle(name, this.remote, this.blobs, {
|
|
1490
|
+
blurhash: this.config.blurhash,
|
|
1491
|
+
logger: this.logger,
|
|
1492
|
+
});
|
|
1377
1493
|
}
|
|
1378
1494
|
|
|
1379
1495
|
/** Cache-free handle. The blob cache reads the remote through this, so a
|
|
1380
1496
|
* cache miss can't loop back into the cache. */
|
|
1381
1497
|
private rawBucket(name: string): BucketHandle {
|
|
1382
|
-
return new BucketHandle(name, this.remote, null
|
|
1498
|
+
return new BucketHandle(name, this.remote, null, {
|
|
1499
|
+
blurhash: this.config.blurhash,
|
|
1500
|
+
logger: this.logger,
|
|
1501
|
+
});
|
|
1383
1502
|
}
|
|
1384
1503
|
|
|
1385
1504
|
/** Blob cache counters for DevTools. */
|
package/src/types.ts
CHANGED
|
@@ -296,6 +296,19 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
296
296
|
* (or `degradeAfterConsecutiveFailures: 0`) to never report degraded.
|
|
297
297
|
*/
|
|
298
298
|
syncHealth?: SyncHealthConfig | false;
|
|
299
|
+
/**
|
|
300
|
+
* Automatic blurhash placeholders for bucket image uploads. On every
|
|
301
|
+
* `bucket.put` of an image path (by extension: webp/png/jpg/jpeg/gif/avif/bmp)
|
|
302
|
+
* the client computes a blurhash and stores it as a tiny sidecar object
|
|
303
|
+
* `<path>.bh` in the same bucket, best-effort. Read it back with
|
|
304
|
+
* `bucket.blurhash(path)` (or the client-solid `useBucketImage`/`BucketImage`
|
|
305
|
+
* helpers) to paint a placeholder until the image is decoded.
|
|
306
|
+
*
|
|
307
|
+
* `true` (the default) enables with 4x3 components; pass
|
|
308
|
+
* `{ componentX, componentY }` to tune detail, or `false` to disable.
|
|
309
|
+
* A per-call `put(path, content, { blurhash })` option overrides this.
|
|
310
|
+
*/
|
|
311
|
+
blurhash?: boolean | { componentX?: number; componentY?: number };
|
|
299
312
|
/**
|
|
300
313
|
* Deadline (ms) for a single outgoing mutation push. Tighter than
|
|
301
314
|
* {@link Sp00kyConfig.database.queryTimeoutMs} because the up-queue drains
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { encode, decode, isBlurhashValid } from 'blurhash';
|
|
2
|
+
|
|
3
|
+
export { encode as encodeBlurhash, decode as decodeBlurhash, isBlurhashValid };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Blurhash generation settings. `true` enables with the defaults below, `false`
|
|
7
|
+
* disables. Resolution order for a put: per-call option > client config >
|
|
8
|
+
* default ON. See {@link Sp00kyConfig.blurhash}.
|
|
9
|
+
*/
|
|
10
|
+
export type BlurhashSetting = boolean | BlurhashEncodeOptions;
|
|
11
|
+
|
|
12
|
+
export interface BlurhashEncodeOptions {
|
|
13
|
+
/** Horizontal detail components, 1-9. Defaults to 4. */
|
|
14
|
+
componentX?: number;
|
|
15
|
+
/** Vertical detail components, 1-9. Defaults to 3. */
|
|
16
|
+
componentY?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where an image's blurhash lives: a tiny sidecar object in the same bucket.
|
|
21
|
+
* Buckets have no per-object metadata channel (`put` is just `.put($content)`),
|
|
22
|
+
* so the hash for `covers/x_t.webp` is the text object `covers/x_t.webp.bh`.
|
|
23
|
+
*/
|
|
24
|
+
export function blurhashSidecarPath(path: string): string {
|
|
25
|
+
return `${path}.bh`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Extensions `bucket.put` treats as images worth hashing. */
|
|
29
|
+
export const BLURHASH_IMAGE_EXTENSIONS = [
|
|
30
|
+
'webp',
|
|
31
|
+
'png',
|
|
32
|
+
'jpg',
|
|
33
|
+
'jpeg',
|
|
34
|
+
'gif',
|
|
35
|
+
'avif',
|
|
36
|
+
'bmp',
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
const IMAGE_EXT_RE = new RegExp(`\\.(${BLURHASH_IMAGE_EXTENSIONS.join('|')})$`, 'i');
|
|
40
|
+
|
|
41
|
+
export function isImagePath(path: string): boolean {
|
|
42
|
+
return IMAGE_EXT_RE.test(path);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Longest edge of the downscale the hash is computed from. Blurhash carries at
|
|
46
|
+
* most 9x9 DCT components, so anything past ~32px is wasted decode work. */
|
|
47
|
+
const ENCODE_MAX_EDGE = 32;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decode `content` as an image and compute its blurhash. Browser-only: returns
|
|
51
|
+
* null (never throws) when image decoding is unavailable (node, workers without
|
|
52
|
+
* canvas), when the bytes are not a decodable image, or on any other failure —
|
|
53
|
+
* a missing hash must never break the upload that triggered it.
|
|
54
|
+
*/
|
|
55
|
+
export async function encodeImageToBlurhash(
|
|
56
|
+
content: string | Uint8Array | Blob,
|
|
57
|
+
options?: BlurhashEncodeOptions
|
|
58
|
+
): Promise<string | null> {
|
|
59
|
+
if (typeof createImageBitmap !== 'function') return null;
|
|
60
|
+
let bitmap: ImageBitmap | null = null;
|
|
61
|
+
try {
|
|
62
|
+
const blob =
|
|
63
|
+
content instanceof Blob
|
|
64
|
+
? content
|
|
65
|
+
: new Blob([content as unknown as BlobPart]);
|
|
66
|
+
bitmap = await createImageBitmap(blob);
|
|
67
|
+
const scale = Math.min(1, ENCODE_MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
|
68
|
+
const width = Math.max(1, Math.round(bitmap.width * scale));
|
|
69
|
+
const height = Math.max(1, Math.round(bitmap.height * scale));
|
|
70
|
+
const canvas =
|
|
71
|
+
typeof OffscreenCanvas !== 'undefined'
|
|
72
|
+
? new OffscreenCanvas(width, height)
|
|
73
|
+
: typeof document !== 'undefined'
|
|
74
|
+
? Object.assign(document.createElement('canvas'), { width, height })
|
|
75
|
+
: null;
|
|
76
|
+
if (!canvas) return null;
|
|
77
|
+
const ctx = canvas.getContext('2d') as
|
|
78
|
+
| OffscreenCanvasRenderingContext2D
|
|
79
|
+
| CanvasRenderingContext2D
|
|
80
|
+
| null;
|
|
81
|
+
if (!ctx) return null;
|
|
82
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
83
|
+
const { data } = ctx.getImageData(0, 0, width, height);
|
|
84
|
+
return encode(data, width, height, options?.componentX ?? 4, options?.componentY ?? 3);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
} finally {
|
|
88
|
+
bitmap?.close();
|
|
89
|
+
}
|
|
90
|
+
}
|