Skip to content
Open
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,16 @@ jobs:
working-directory: pr-template
run: npm install

# The scaffold's own vitest suite, run against the tarballs built above
# rather than the published versions template/package.json pins. This is
# the only CI check that exercises the template's example test, and the
# only one that consumes @databricks/appkit/testing the way a customer
# does — through a real npm install of a packed tarball. Before the zip,
# so a broken example fails the build instead of shipping.
- name: Run the template's own tests
working-directory: pr-template
run: npm test

# npm install above runs under the JFrog .npmrc (setup-jfrog-npm), which
# bakes internal registry URLs into the regenerated lock. Rewrite them back
# to public npm and fail-closed if any non-public registry remains, so the
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ tmp
node_modules
.env

# Staging dir and zip produced by tools/prepare-template-artifact.ts
pr-template
appkit-template-*.zip

coverage

*.tsbuildinfo
Expand All @@ -16,3 +20,5 @@ coverage
internal

.isaac/

.codex-tmp/
7 changes: 2 additions & 5 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,8 @@
{
"patterns": [
{
"group": [
"@databricks/sdk-experimental",
"@databricks/sdk-experimental/**"
],
"message": "Import the Databricks SDK only through the wrapper in packages/shared/src/workspace-client. Add a re-export there if you need a new symbol."
"group": ["@databricks/sdk-*", "@databricks/sdk-*/**"],
"message": "Import the Databricks SDK only through the wrapper in packages/shared/src/workspace-client (legacy.ts for @databricks/sdk-experimental, modular.ts for the modular @databricks/sdk-* packages). Add a re-export there if you need a new symbol."
}
]
}
Expand Down
18 changes: 9 additions & 9 deletions docs/docs/api/appkit/Function.createApp.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions docs/docs/api/appkit/Interface.WorkspaceClient.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

421 changes: 408 additions & 13 deletions docs/docs/plugins/testing.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/static/appkit-ui/styles.gen.css
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,9 @@
.\!m-0 {
margin: calc(var(--spacing) * 0) !important;
}
.m-1 {
margin: calc(var(--spacing) * 1);
}
.-mx-1 {
margin-inline: calc(var(--spacing) * -1);
}
Expand Down Expand Up @@ -717,6 +720,9 @@
.w-\(--sidebar-width\) {
width: var(--sidebar-width);
}
.w-1 {
width: calc(var(--spacing) * 1);
}
.w-1\/2 {
width: calc(1/2 * 100%);
}
Expand Down
10 changes: 9 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@
],
"workspaces": {
"packages/appkit": {
"ignoreDependencies": ["vitest", "@databricks/sdk-experimental"]
"ignoreDependencies": [
"vitest",
"@databricks/sdk-auth",
"@databricks/sdk-core",
"@databricks/sdk-experimental",
"@databricks/sdk-options",
"@databricks/sdk-statementexecution",
"@databricks/sdk-warehouses"
]
},
"packages/appkit-ui": {
"ignoreDependencies": ["tailwindcss", "tw-animate-css"]
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@
"protobufjs@<7.6.2": "7.6.2",
"qs@<6.15.2": "6.15.2",
"size-sensor": "1.0.3"
},
"patchedDependencies": {
"@databricks/sdk-statementexecution@0.46.0": "patches/@databricks__sdk-statementexecution@0.46.0.patch"
}
}
}
5 changes: 5 additions & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@
"dependencies": {
"@ast-grep/napi": "0.37.0",
"@databricks/lakebase": "workspace:*",
"@databricks/sdk-auth": "0.46.0",
"@databricks/sdk-core": "0.46.0",
"@databricks/sdk-experimental": "0.17.0",
"@databricks/sdk-options": "0.46.0",
"@databricks/sdk-statementexecution": "0.46.0",
"@databricks/sdk-warehouses": "0.46.0",
"@mlflow/core": "0.4.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/api-logs": "0.219.0",
Expand Down
18 changes: 18 additions & 0 deletions packages/appkit/src/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ export class CacheManager {
if (!CacheManager.initPromise) {
CacheManager.initPromise = CacheManager.create(userConfig).then(
(instance) => {
// Publishes unconditionally: safe only because every getInstance() is
// awaited before any reset(), so a reset() can never land mid-init and
// this can never publish over it. A future unawaited-init caller would
// reintroduce that stale-publish race (the removed `generation` guard).
CacheManager.instance = instance;
return instance;
},
Expand Down Expand Up @@ -557,6 +561,20 @@ export class CacheManager {
await this.storage.close();
}

/**
* Drop the singleton so the next {@link getInstance} builds a fresh manager.
*
* Both fields must clear — `getInstance()` falls back to `initPromise` when
* `instance` is null. A pointer drop, not teardown: call {@link close} first
* or the old storage leaks (a `pg.Pool` under `PersistentStorage`).
*
* @internal
*/
static reset(): void {
CacheManager.instance = null;
CacheManager.initPromise = null;
}

/**
* Check if the storage is healthy
* @returns Promise of true if the storage is healthy, false otherwise
Expand Down
100 changes: 100 additions & 0 deletions packages/appkit/src/cache/tests/cache-manager-reset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { CacheEntry } from "shared";
import { afterEach, beforeEach, describe, expect, test } from "vitest";

import { CacheManager } from "..";
import { InitializationError } from "../../errors";
import { InMemoryStorage } from "../storage/memory";

/**
* `getInstance()` returns the existing instance, so after `cache.close()` the
* singleton still points at closed storage — under `PersistentStorage` an ended
* `pg.Pool`. Every test passes explicit `storage` so nothing probes Lakebase.
*/
describe("CacheManager.reset", () => {
beforeEach(() => {
CacheManager.reset();
});

afterEach(() => {
CacheManager.reset();
});

function storage() {
return new InMemoryStorage({ enabled: true, maxSize: 100 } as never);
}

test("the next getInstance() builds a fresh instance, not the closed one", async () => {
const first = await CacheManager.getInstance({ storage: storage() });
await first.close();

CacheManager.reset();
const second = await CacheManager.getInstance({ storage: storage() });

expect(second).not.toBe(first);

// The point of the fix: the fresh instance's storage is live, so a
// write-then-read round-trips instead of hitting closed storage.
const key = second.generateKey(["reset-probe"], "test-user");
await second.set(key, { ok: true });
await expect(second.get(key)).resolves.toEqual({ ok: true });
});

test("without a reset, getInstance() keeps returning the same instance", async () => {
// The regression guard for the *unchanged* path: a single boot with no reset
// must behave exactly as before.
const first = await CacheManager.getInstance({ storage: storage() });
const second = await CacheManager.getInstance({ storage: storage() });

expect(second).toBe(first);
});

test("getInstanceSync throws after a reset", async () => {
await CacheManager.getInstance({ storage: storage() });
expect(() => CacheManager.getInstanceSync()).not.toThrow();

CacheManager.reset();

// Reset is a pointer drop, so the sync accessor is back to its
// not-initialized contract rather than handing out a stale manager.
expect(() => CacheManager.getInstanceSync()).toThrow(InitializationError);
});

test("without a reset, the next boot reuses storage the last teardown closed", async () => {
// Models PersistentStorage, whose close() is `pool.end()` — permanent.
// InMemoryStorage.close() merely clears a Map and stays usable, which is why
// an in-memory test cannot show this and why the bug hid for so long.
class EndableStorage extends InMemoryStorage {
private ended = false;
override async close(): Promise<void> {
this.ended = true;
}
override async set<T>(key: string, entry: CacheEntry<T>): Promise<void> {
if (this.ended)
throw new Error("Cannot use a pool after calling end()");
return super.set(key, entry);
}
}
const endable = () =>
new EndableStorage({ enabled: true, maxSize: 100 } as never);

const first = await CacheManager.getInstance({ storage: endable() });
await first.close();

// The bug, with no reset in between: getInstance() hands back the same
// manager, still pointing at storage that has been ended.
const stale = await CacheManager.getInstance({ storage: endable() });
expect(stale).toBe(first);
await expect(
stale.set(stale.generateKey(["x"], "test-user"), { v: 1 }),
).rejects.toThrow(/after calling end/);

// The fix: reset drops the pointer, so the next boot builds over live
// storage and the same write succeeds.
CacheManager.reset();
const fresh = await CacheManager.getInstance({ storage: endable() });
expect(fresh).not.toBe(first);
const key = fresh.generateKey(["x"], "test-user");
await fresh.set(key, { v: 1 });
await expect(fresh.get(key)).resolves.toEqual({ v: 1 });
});
});
Loading
Loading