Skip to content
Warlock.js v4

Set Options

The third argument to cache.set() accepts three shapes — a positional TTL (number or string) for the common case, and a rich options object when you need more than just expiration.

import { cache } from "@warlock.js/cache";
// 1. Number of seconds (back-compat)
await cache.set("name", "John Doe", 600);
// 2. Human-readable duration string (parsed via ms)
await cache.set("name", "John Doe", "10m");
await cache.set("session", token, "1h");
await cache.set("report", report, "7d");
// 3. Rich options object
await cache.set("user.1", user, {
ttl: "1h",
tags: ["users", "active"],
onConflict: "create",
});

See Duration Strings for the full grammar of accepted string forms.

KeyTypePurpose
ttlnumber | stringRelative expiry. Mutually exclusive with expiresAt.
expiresAtnumber | DateAbsolute deadline. Must be in the future. Mutually exclusive with ttl.
tagsstring[]Inline tag list — equivalent to cache.tags([...]).set(...).
onConflict"create" | "update" | "upsert"Conditional-write policy. Defaults to "upsert".
driverstringPer-call driver override by registered name.
vectornumber[]Embedding indexed alongside the entry for similarity retrieval. Drivers without similarity support throw CacheUnsupportedError.

:::info Validation rules

  • Passing both ttl and expiresAt throws CacheConfigurationError.
  • An expiresAt in the past throws CacheConfigurationError.
  • Invalid duration strings ("2 weeksandahalf") throw CacheConfigurationError. :::

Use ttl for relative expiry (“cache for 1 hour from now”). Use expiresAt when the deadline is pinned to a specific wall-clock moment — JWT expiry, session close, scheduled campaign end.

// Relative — "1 hour from now"
await cache.set("session", session, { ttl: "1h" });
// Absolute — aligned to JWT expiry
await cache.set("session", session, { expiresAt: jwt.exp * 1000 });
// Absolute — aligned to a Date
await cache.set("campaign", data, {
expiresAt: new Date("2026-12-31T23:59:59Z"),
});

Passing both is rejected at the call site — two ways to say the same thing silently drift over time.

Controls what happens when the key already exists (or doesn’t).

// "create" — set only if the key is missing
const workerId = `worker.${process.pid}`; // any identifying value
const result = await cache.set("lock.orders", workerId, {
onConflict: "create",
ttl: "5m",
});
// { wasSet: true, existing: null } on acquire
// { wasSet: false, existing: <prior workerId> } if someone else holds it
if (!result.wasSet) {
throw new Error(`Another worker owns this lock: ${result.existing}`);
}
// "update" — set only if the key exists (don't resurrect expired entries)
await cache.set("user.1", patch, { onConflict: "update" });
// "upsert" — the default. Overwrites regardless.
await cache.set("user.1", user);

Conditional writes return a CacheSetResult:

type CacheSetResult<T = any> = {
wasSet: boolean;
existing: T | null; // only populated on "create" rejections
};

Unconditional "upsert" writes return the driver instance or the stored value, matching the legacy shape.

:::tip Redis equivalence "create" maps to Redis SET … NX natively; "update" maps to SET … XX. Other drivers emulate the behavior in-process. You get the same semantics regardless of driver. :::

Equivalent to the fluent form, terser at the call site:

// Inline
await cache.set("user.1", user, { tags: ["users", "tenant-42"] });
// Fluent (useful when you already have a tagged instance)
const users = cache.tags(["users"]);
await users.set("user.1", user);

Both invalidate via the same path:

await cache.tags(["users"]).invalidate();

See Cache Tags for the full invalidation workflow.

When most writes go to your default driver but one call needs a different one, pass driver in the options object. The manager loads (and connects) the override driver lazily on first use, then routes that single operation without mutating currentDriver.

// Audit events always land in Redis, regardless of default driver
await cache.set("audit.event", event, {
driver: "redis",
ttl: "30d",
});
// Normal usage still uses the default driver
await cache.set("user.1", user, "1h");

The override driver must be registered in your configuration:

cache.setCacheConfigurations({
default: "memory",
drivers: {
memory: MemoryCacheDriver,
redis: RedisCacheDriver,
},
options: { /* ... */ },
});

A distributed lock makes sure only one process runs a block of code at a time, even across multiple servers. The onConflict: "create" pattern is the portable way to build one — Redis-native on Redis, single-process elsewhere.

async function withLock<T>(
key: string,
ttl: string,
work: () => Promise<T>,
): Promise<T | null> {
// process.pid is just an identifying value so you can see which worker
// holds the lock when debugging. Any unique string works.
const acquired = await cache.set(key, process.pid, {
onConflict: "create",
ttl, // safety net: auto-release if we crash before remove()
});
if (!acquired.wasSet) {
return null; // someone else holds the lock — skip this work
}
try {
return await work();
} finally {
await cache.remove(key);
}
}
// Usage — build a heavy report, but only run the job on one server even if
// three servers received the request at the same time.
const result = await withLock("lock.build-report.42", "2m", async () => {
const report = await db.reports.generate(42); // expensive
await cache.set("report.42", report, "1h");
return report;
});

See Stampede Prevention for the single-node variant using remember().

Every call site using the old positional-TTL shape still works:

await cache.set("k", v); // no TTL — driver default
await cache.set("k", v, 3600); // seconds
await cache.set("k", v, undefined); // same as no TTL