@spooky-sync/core 0.0.1-canary.198 → 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 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
- put(path: string, content: string | Uint8Array | Blob): Promise<void>;
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. */
@@ -7092,8 +7093,8 @@ function selfAllowlistedVariant(flag, userId) {
7092
7093
 
7093
7094
  //#endregion
7094
7095
  //#region src/modules/devtools/index.ts
7095
- const CORE_VERSION = "0.0.1-canary.198";
7096
- const WASM_VERSION = "0.0.1-canary.198";
7096
+ const CORE_VERSION = "0.0.1-canary.199";
7097
+ const WASM_VERSION = "0.0.1-canary.199";
7097
7098
  const SURREAL_VERSION = "3.0.3";
7098
7099
  var DevToolsService = class DevToolsService {
7099
7100
  eventsHistory = [];
@@ -11222,6 +11223,65 @@ function createBlobCache(opts) {
11222
11223
  });
11223
11224
  }
11224
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
+
11225
11285
  //#endregion
11226
11286
  //#region src/sp00ky.ts
11227
11287
  /** Coerce whatever the `.get()` RPC hands back into a Blob. */
@@ -11233,18 +11293,85 @@ function bucketContentToBlob(content) {
11233
11293
  if (ArrayBuffer.isView(content)) return new Blob([content]);
11234
11294
  return null;
11235
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();
11236
11303
  var BucketHandle = class {
11237
- constructor(bucketName, remote, blobs) {
11304
+ constructor(bucketName, remote, blobs, settings) {
11238
11305
  this.bucketName = bucketName;
11239
11306
  this.remote = remote;
11240
11307
  this.blobs = blobs;
11308
+ this.settings = settings;
11241
11309
  }
11242
- async put(path, content) {
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;
11315
+ }
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);
11243
11319
  await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
11244
11320
  await this.blobs?.invalidate({
11245
11321
  bucket: this.bucketName,
11246
11322
  path
11247
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
+ }
11248
11375
  }
11249
11376
  async get(path) {
11250
11377
  const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".get();`);
@@ -11307,6 +11434,17 @@ var BucketHandle = class {
11307
11434
  bucket: this.bucketName,
11308
11435
  path
11309
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
+ }
11310
11448
  }
11311
11449
  async exists(path) {
11312
11450
  const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".exists();`);
@@ -11524,7 +11662,7 @@ var Sp00kyClient = class {
11524
11662
  return new TabsCoordinator({
11525
11663
  tabId,
11526
11664
  fingerprint: computeTabsFingerprint({
11527
- coreVersion: "0.0.1-canary.198",
11665
+ coreVersion: "0.0.1-canary.199",
11528
11666
  schemaHash: hash53(this.config.schemaSurql),
11529
11667
  endpoint: this.config.database.endpoint ?? "",
11530
11668
  namespace: this.config.database.namespace,
@@ -12074,12 +12212,18 @@ var Sp00kyClient = class {
12074
12212
  return this.dataModule.run(backend, path, payload, options);
12075
12213
  }
12076
12214
  bucket(name) {
12077
- 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
+ });
12078
12219
  }
12079
12220
  /** Cache-free handle. The blob cache reads the remote through this, so a
12080
12221
  * cache miss can't loop back into the cache. */
12081
12222
  rawBucket(name) {
12082
- 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
+ });
12083
12227
  }
12084
12228
  /** Blob cache counters for DevTools. */
12085
12229
  getBlobCacheStats() {
@@ -12118,4 +12262,4 @@ var Sp00kyClient = class {
12118
12262
  };
12119
12263
 
12120
12264
  //#endregion
12121
- 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.198",
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.198",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.198",
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';
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
- async put(path: string, content: string | Uint8Array | Blob): Promise<void> {
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
+ }