Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/typescript.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: typescript

on:
push:
pull_request:

jobs:
gcache-ts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
version: 10.33.0
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm ts:gcache:typecheck
- run: pnpm ts:gcache:test
- run: pnpm ts:gcache:build
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,8 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

# Node / TypeScript
node_modules/
packages/*/dist/
packages/*/coverage/
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "gcache-monorepo",
"private": true,
"packageManager": "pnpm@10.33.0",
"workspaces": [
"packages/*"
],
"scripts": {
"ts:gcache:build": "pnpm --filter @rungalileo/gcache build",
"ts:gcache:test": "pnpm --filter @rungalileo/gcache test",
"ts:gcache:typecheck": "pnpm --filter @rungalileo/gcache typecheck"
}
}
89 changes: 89 additions & 0 deletions packages/gcache-ts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# @rungalileo/gcache

TypeScript port of GCache. Milestone 1 intentionally ships a usable local-only library first: explicit enabled contexts, stable key construction, local TTL caching, and fail-open behavior.

## Install

```bash
pnpm add @rungalileo/gcache
```

## Quick start

```ts
import { GCache, GCacheKeyConfig } from "@rungalileo/gcache";

const gcache = new GCache();

const getUser = gcache.cached({
keyType: "user_id",
useCase: "GetUser",
id: ([userId]: [string]) => userId,
defaultConfig: GCacheKeyConfig.enabled(60),
})(async (userId: string) => {
return db.fetchUser(userId);
});

// Caching is disabled by default.
await getUser("123");

// Enable caching for one async scope.
const user = await gcache.enable(async () => {
return await getUser("123");
});
```

## Enabled context

The TypeScript port uses Node `AsyncLocalStorage` to mirror Python's `with gcache.enable():` safety model.

```ts
await gcache.enable(async () => {
await getUser("123"); // cached

await gcache.disable(async () => {
await updateUser("123", patch); // uncached reads here
});

await getUser("123"); // cached again
});
```

- Default is disabled.
- Enabled state is async-scope-local, not process-global.
- Nested `enable` / `disable` scopes restore the previous behavior when the callback completes.

## Explicit key builders

TypeScript does not have safe Python-style function argument introspection after transpilation/bundling. Use explicit key builders instead:

```ts
const searchPosts = gcache.cached({
keyType: "user_id",
useCase: "SearchPosts",
id: ([userId]: [string, number, string]) => userId,
args: ([, page, filter]) => ({ page, filter }),
defaultConfig: GCacheKeyConfig.enabled(60),
})(async (userId: string, page: number, filter: string) => {
return db.searchPosts(userId, page, filter);
});
```

## Milestone 1 scope

Included:

- Local TTL cache
- Explicit enabled context
- Explicit key builders
- Duplicate and reserved use-case validation
- `delete` and `flushAll`
- Fail-open behavior for key/config/cache errors

Not included yet:

- Redis
- Runtime ramp controls
- Prometheus metrics
- Targeted invalidation
- Framework middleware helpers
37 changes: 37 additions & 0 deletions packages/gcache-ts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@rungalileo/gcache",
"version": "0.1.0",
"description": "TypeScript port of GCache, starting with explicit-context local caching.",
"license": "MIT",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"typecheck": "tsc --noEmit",
"test": "vitest run --coverage",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitest/coverage-v8": "^4.0.14",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"vitest": "^4.0.14"
},
"engines": {
"node": ">=18.17"
}
}
45 changes: 45 additions & 0 deletions packages/gcache-ts/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { GCacheKey } from "./key.js";

export enum CacheLayer {
NOOP = "noop",
LOCAL = "local",
REMOTE = "remote",
}

export type LayerConfig = Partial<Record<CacheLayer, number>>;

export class GCacheKeyConfig {
readonly ttlSec: LayerConfig;
readonly ramp: LayerConfig;

constructor(config: { ttlSec: LayerConfig; ramp: LayerConfig }) {
this.ttlSec = { ...config.ttlSec };
this.ramp = { ...config.ramp };
}

static enabled(ttlSec: number): GCacheKeyConfig {
return new GCacheKeyConfig({
ttlSec: {
[CacheLayer.LOCAL]: ttlSec,
[CacheLayer.REMOTE]: ttlSec,
[CacheLayer.NOOP]: ttlSec,
},
ramp: {
[CacheLayer.LOCAL]: 100,
[CacheLayer.REMOTE]: 100,
[CacheLayer.NOOP]: 100,
},
});
}
}

export type CacheConfigProvider = (key: GCacheKey) => Promise<GCacheKeyConfig | null>;

export type Logger = Pick<Console, "debug" | "error" | "warn">;

export interface GCacheConfig {
readonly cacheConfigProvider?: CacheConfigProvider;
readonly urnPrefix?: string;
readonly logger?: Logger;
readonly localMaxSize?: number;
}
21 changes: 21 additions & 0 deletions packages/gcache-ts/src/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { AsyncLocalStorage } from "node:async_hooks";

export class GCacheContext {
private readonly storage = new AsyncLocalStorage<boolean>();

isEnabled(): boolean {
return this.storage.getStore() ?? false;
}

enable<T>(fn: () => T | Promise<T>): Promise<T> {
return this.run(true, fn);
}

disable<T>(fn: () => T | Promise<T>): Promise<T> {
return this.run(false, fn);
}

private async run<T>(enabled: boolean, fn: () => T | Promise<T>): Promise<T> {
return await this.storage.run(enabled, async () => await fn());
}
}
24 changes: 24 additions & 0 deletions packages/gcache-ts/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export class GCacheError extends Error {
constructor(message: string) {
super(message);
this.name = new.target.name;
}
}

export class UseCaseIsAlreadyRegisteredError extends GCacheError {
constructor(useCase: string) {
super(`Use case already registered: ${useCase}`);
}
}

export class UseCaseNameIsReservedError extends GCacheError {
constructor(useCase: string) {
super(`Use case name is reserved: ${useCase}`);
}
}

export class MissingKeyConfigError extends GCacheError {
constructor(useCase: string) {
super(`Missing key config for use case: ${useCase}`);
}
}
118 changes: 118 additions & 0 deletions packages/gcache-ts/src/gcache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { GCacheConfig, type CacheConfigProvider, type Logger } from "./config.js";
import { GCacheContext } from "./context.js";
import { UseCaseIsAlreadyRegisteredError, UseCaseNameIsReservedError } from "./errors.js";
import { GCacheKey, normalizeArgs } from "./key.js";
import type { Serializer } from "./serializer.js";
import { LocalCache } from "./internal/local-cache.js";

type Awaitable<T> = T | Promise<T>;
type CacheableArgs = readonly unknown[];
type CacheArgs = Record<string, string | number | boolean | bigint | null | undefined>;

export interface CachedOptions<Args extends CacheableArgs> {
readonly keyType: string;
readonly useCase: string;
readonly id: (args: Args) => string | number | bigint;
readonly args?: (args: Args) => CacheArgs;
readonly defaultConfig?: import("./config.js").GCacheKeyConfig | null;
readonly serializer?: Serializer<unknown> | null;
}

const DEFAULT_LOCAL_MAX_SIZE = 10_000;
const defaultConfigProvider: CacheConfigProvider = async () => null;
const defaultLogger: Logger = console;

export class GCache {
private readonly context = new GCacheContext();
private readonly localCache: LocalCache;
private readonly useCases = new Set<string>();
private readonly configProvider: CacheConfigProvider;
private readonly urnPrefix: string;
private readonly logger: Logger;

constructor(config: GCacheConfig = {}) {
this.configProvider = config.cacheConfigProvider ?? defaultConfigProvider;
this.urnPrefix = config.urnPrefix ?? "urn";
this.logger = config.logger ?? defaultLogger;
this.localCache = new LocalCache(this.configProvider, config.localMaxSize ?? DEFAULT_LOCAL_MAX_SIZE);
}

enable<T>(fn: () => Awaitable<T>): Promise<T> {
return this.context.enable(fn);
}

disable<T>(fn: () => Awaitable<T>): Promise<T> {
return this.context.disable(fn);
}

withEnabled<T>(fn: () => Awaitable<T>): Promise<T> {
return this.enable(fn);
}

withDisabled<T>(fn: () => Awaitable<T>): Promise<T> {
return this.disable(fn);
}

isEnabled(): boolean {
return this.context.isEnabled();
}

cached<Args extends CacheableArgs>(
options: CachedOptions<Args>,
): <Value>(fn: (...args: Args) => Awaitable<Value>) => (...args: Args) => Promise<Value> {
this.registerUseCase(options.useCase);

return <Value>(fn: (...args: Args) => Awaitable<Value>) => {
return async (...args: Args): Promise<Value> => {
if (!this.isEnabled()) {
return await fn(...args);
}

let key: GCacheKey;
try {
key = this.createKey(options, args);
} catch (error) {
this.logger.error("Could not construct GCache key", error);
return await fn(...args);
}

try {
return await this.localCache.get(key, async () => await fn(...args));
} catch (error) {
this.logger.error("Error getting value from local cache", error);
return await fn(...args);
}
};
};
}

async delete(key: GCacheKey): Promise<boolean> {
return await this.localCache.delete(key);
}

async flushAll(): Promise<void> {
await this.localCache.flushAll();
}

private registerUseCase(useCase: string): void {
if (useCase === "watermark") {
throw new UseCaseNameIsReservedError(useCase);
}
if (this.useCases.has(useCase)) {
throw new UseCaseIsAlreadyRegisteredError(useCase);
}
this.useCases.add(useCase);
}

private createKey<Args extends CacheableArgs>(options: CachedOptions<Args>, args: Args): GCacheKey {
return new GCacheKey({
keyType: options.keyType,
id: String(options.id(args)),
useCase: options.useCase,
args: normalizeArgs(options.args?.(args) ?? {}),
urnPrefix: this.urnPrefix,
defaultConfig: options.defaultConfig ?? null,
serializer: (options.serializer as Serializer<unknown> | null | undefined) ?? null,
});
}
}
Loading
Loading