simplemdg-dev-cli 2.5.0 → 2.6.0

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.
Files changed (69) hide show
  1. package/README.md +33 -0
  2. package/USER_GUIDE.md +57 -0
  3. package/dist/commands/cache.command.d.ts +2 -0
  4. package/dist/commands/cache.command.js +129 -0
  5. package/dist/commands/cache.command.js.map +1 -0
  6. package/dist/commands/cf.command.js +201 -122
  7. package/dist/commands/cf.command.js.map +1 -1
  8. package/dist/commands/gitlab.command.js +33 -23
  9. package/dist/commands/gitlab.command.js.map +1 -1
  10. package/dist/core/cache/smart-cache-events.d.ts +3 -0
  11. package/dist/core/cache/smart-cache-events.js +20 -0
  12. package/dist/core/cache/smart-cache-events.js.map +1 -0
  13. package/dist/core/cache/smart-cache-manager.d.ts +20 -0
  14. package/dist/core/cache/smart-cache-manager.js +148 -0
  15. package/dist/core/cache/smart-cache-manager.js.map +1 -0
  16. package/dist/core/cache/smart-cache-store.d.ts +8 -0
  17. package/dist/core/cache/smart-cache-store.js +74 -0
  18. package/dist/core/cache/smart-cache-store.js.map +1 -0
  19. package/dist/core/cache/smart-cache.d.ts +18 -0
  20. package/dist/core/cache/smart-cache.js +117 -0
  21. package/dist/core/cache/smart-cache.js.map +1 -0
  22. package/dist/core/cache/smart-cache.types.d.ts +62 -0
  23. package/dist/core/cache/smart-cache.types.js +17 -0
  24. package/dist/core/cache/smart-cache.types.js.map +1 -0
  25. package/dist/core/cf/cf-target-cache.d.ts +7 -0
  26. package/dist/core/cf/cf-target-cache.js +58 -0
  27. package/dist/core/cf/cf-target-cache.js.map +1 -0
  28. package/dist/core/cf/cf-target.types.d.ts +11 -0
  29. package/dist/core/cf/cf-target.types.js +11 -0
  30. package/dist/core/cf/cf-target.types.js.map +1 -0
  31. package/dist/core/db/db-studio-client.d.ts +1 -1
  32. package/dist/core/db/db-studio-client.js +173 -44
  33. package/dist/core/db/db-studio-client.js.map +1 -1
  34. package/dist/core/db/db-studio-server.js +125 -0
  35. package/dist/core/db/db-studio-server.js.map +1 -1
  36. package/dist/core/db/db-studio-styles.d.ts +1 -1
  37. package/dist/core/db/db-studio-styles.js +36 -0
  38. package/dist/core/db/db-studio-styles.js.map +1 -1
  39. package/dist/core/db/db-types.d.ts +54 -0
  40. package/dist/core/db/studio/sql-formatter.d.ts +25 -0
  41. package/dist/core/db/studio/sql-formatter.js +139 -0
  42. package/dist/core/db/studio/sql-formatter.js.map +1 -0
  43. package/dist/core/db/studio/studio-settings.d.ts +4 -0
  44. package/dist/core/db/studio/studio-settings.js +39 -0
  45. package/dist/core/db/studio/studio-settings.js.map +1 -0
  46. package/dist/core/db/studio/workspace-cache.d.ts +3 -0
  47. package/dist/core/db/studio/workspace-cache.js +51 -0
  48. package/dist/core/db/studio/workspace-cache.js.map +1 -0
  49. package/dist/index.js +3 -1
  50. package/dist/index.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/commands/cache.command.ts +159 -0
  53. package/src/commands/cf.command.ts +232 -129
  54. package/src/commands/gitlab.command.ts +37 -21
  55. package/src/core/cache/smart-cache-events.ts +20 -0
  56. package/src/core/cache/smart-cache-manager.ts +169 -0
  57. package/src/core/cache/smart-cache-store.ts +83 -0
  58. package/src/core/cache/smart-cache.ts +97 -0
  59. package/src/core/cache/smart-cache.types.ts +79 -0
  60. package/src/core/cf/cf-target-cache.ts +61 -0
  61. package/src/core/cf/cf-target.types.ts +17 -0
  62. package/src/core/db/db-studio-client.ts +173 -44
  63. package/src/core/db/db-studio-server.ts +109 -1
  64. package/src/core/db/db-studio-styles.ts +36 -0
  65. package/src/core/db/db-types.ts +61 -0
  66. package/src/core/db/studio/sql-formatter.ts +139 -0
  67. package/src/core/db/studio/studio-settings.ts +36 -0
  68. package/src/core/db/studio/workspace-cache.ts +51 -0
  69. package/src/index.ts +3 -1
@@ -0,0 +1,169 @@
1
+ import { emitCacheEvent } from "./smart-cache-events";
2
+ import { readEntry, writeEntry } from "./smart-cache-store";
3
+ import type {
4
+ TCacheStatus,
5
+ TSmartCacheEntry,
6
+ TSmartCacheReadOptions,
7
+ TSmartCacheResult,
8
+ } from "./smart-cache.types";
9
+
10
+ const CACHE_VERSION = 1;
11
+
12
+ /** In-memory registry so concurrent callers share a single network refresh. */
13
+ const pendingRefreshes = new Map<string, Promise<unknown>>();
14
+
15
+ function fullKey(namespace: string, key: string): string {
16
+ return `${namespace}::${key}`;
17
+ }
18
+
19
+ export function computeCacheStatus(entry: Pick<TSmartCacheEntry<unknown>, "updatedAt" | "ttlMs">, now = Date.now()): TCacheStatus {
20
+ if (!Number.isFinite(entry.ttlMs)) {
21
+ return "fresh";
22
+ }
23
+
24
+ const age = now - new Date(entry.updatedAt).getTime();
25
+
26
+ if (age < entry.ttlMs) {
27
+ return "fresh";
28
+ }
29
+
30
+ if (age < entry.ttlMs * 8) {
31
+ return "stale";
32
+ }
33
+
34
+ return "expired";
35
+ }
36
+
37
+ function buildEntry<TData>(namespace: string, key: string, data: TData, ttlMs: number, previous?: TSmartCacheEntry<TData>): TSmartCacheEntry<TData> {
38
+ const now = new Date().toISOString();
39
+ return {
40
+ key,
41
+ data,
42
+ createdAt: previous?.createdAt ?? now,
43
+ updatedAt: now,
44
+ expiresAt: Number.isFinite(ttlMs) ? new Date(Date.now() + ttlMs).toISOString() : undefined,
45
+ source: "network",
46
+ status: "fresh",
47
+ refreshState: "success",
48
+ lastRefreshStartedAt: previous?.lastRefreshStartedAt,
49
+ lastRefreshFinishedAt: now,
50
+ lastRefreshError: undefined,
51
+ ttlMs,
52
+ version: CACHE_VERSION,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Start (or join) a deduplicated background refresh for a cache key. Resolves
58
+ * with the fresh data; on failure the old cache entry is preserved and the
59
+ * error is recorded in metadata.
60
+ */
61
+ export function refreshCache<TData>(options: { namespace: string; key: string; ttlMs: number; resource?: string; fetcher: () => Promise<TData> }): Promise<TData> {
62
+ const id = fullKey(options.namespace, options.key);
63
+ const existing = pendingRefreshes.get(id);
64
+
65
+ if (existing) {
66
+ return existing as Promise<TData>;
67
+ }
68
+
69
+ const resource = options.resource ?? options.namespace;
70
+ emitCacheEvent({ type: "cache-refresh-started", key: options.key, resource });
71
+
72
+ const promise = (async (): Promise<TData> => {
73
+ try {
74
+ const data = await options.fetcher();
75
+ const previous = await readEntry<TData>(options.namespace, options.key);
76
+ const entry = buildEntry(options.namespace, options.key, data, options.ttlMs, previous);
77
+ await writeEntry(options.namespace, options.key, entry);
78
+ emitCacheEvent({ type: "cache-refresh-success", key: options.key, resource, updatedAt: entry.updatedAt });
79
+ return data;
80
+ } catch (error) {
81
+ const message = error instanceof Error ? error.message : String(error);
82
+ const previous = await readEntry<TData>(options.namespace, options.key);
83
+ if (previous) {
84
+ previous.refreshState = "failed";
85
+ previous.lastRefreshFinishedAt = new Date().toISOString();
86
+ previous.lastRefreshError = message;
87
+ await writeEntry(options.namespace, options.key, previous).catch(() => undefined);
88
+ }
89
+ emitCacheEvent({ type: "cache-refresh-failed", key: options.key, resource, error: message });
90
+ throw error;
91
+ } finally {
92
+ pendingRefreshes.delete(id);
93
+ }
94
+ })();
95
+
96
+ pendingRefreshes.set(id, promise);
97
+ return promise;
98
+ }
99
+
100
+ /**
101
+ * Smart cache read: returns cached data immediately under stale-while-revalidate
102
+ * (the default) and refreshes in the background, or follows the requested mode.
103
+ */
104
+ export async function smartRead<TData>(options: TSmartCacheReadOptions<TData>): Promise<TSmartCacheResult<TData>> {
105
+ const mode = options.mode ?? "stale-while-revalidate";
106
+ const entry = await readEntry<TData>(options.namespace, options.key);
107
+ const status = entry ? computeCacheStatus(entry) : "missing";
108
+
109
+ const networkOnce = async (): Promise<TSmartCacheResult<TData>> => {
110
+ const data = await refreshCache({ namespace: options.namespace, key: options.key, ttlMs: options.ttlMs, resource: options.namespace, fetcher: options.fetcher });
111
+ return { data, fromCache: false, cacheStatus: "fresh", isRefreshing: false, updatedAt: new Date().toISOString() };
112
+ };
113
+
114
+ if (mode === "network-only") {
115
+ return networkOnce();
116
+ }
117
+
118
+ if (mode === "cache-only") {
119
+ if (!entry) {
120
+ throw new Error(`No cached data for ${options.namespace}::${options.key}`);
121
+ }
122
+ return { data: entry.data, fromCache: true, cacheStatus: status === "missing" ? "expired" : status, isRefreshing: false, updatedAt: entry.updatedAt };
123
+ }
124
+
125
+ if (mode === "network-first") {
126
+ try {
127
+ return await networkOnce();
128
+ } catch (error) {
129
+ if (entry) {
130
+ return { data: entry.data, fromCache: true, cacheStatus: status === "missing" ? "expired" : status, isRefreshing: false, updatedAt: entry.updatedAt };
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ if (mode === "cache-first") {
137
+ if (entry) {
138
+ return { data: entry.data, fromCache: true, cacheStatus: status === "missing" ? "expired" : status, isRefreshing: false, updatedAt: entry.updatedAt };
139
+ }
140
+ return networkOnce();
141
+ }
142
+
143
+ // stale-while-revalidate (default)
144
+ if (!entry) {
145
+ return networkOnce();
146
+ }
147
+
148
+ const shouldRevalidate = options.revalidateWhenFresh !== false || status !== "fresh";
149
+ let refreshPromise: Promise<TData> | undefined;
150
+
151
+ if (shouldRevalidate) {
152
+ refreshPromise = refreshCache({ namespace: options.namespace, key: options.key, ttlMs: options.ttlMs, resource: options.namespace, fetcher: options.fetcher });
153
+ // Avoid an unhandled rejection if the caller never awaits it.
154
+ refreshPromise.catch(() => undefined);
155
+ }
156
+
157
+ return {
158
+ data: entry.data,
159
+ fromCache: true,
160
+ cacheStatus: status === "missing" ? "expired" : status,
161
+ isRefreshing: Boolean(refreshPromise),
162
+ updatedAt: entry.updatedAt,
163
+ refreshPromise,
164
+ };
165
+ }
166
+
167
+ export function isRefreshPending(namespace: string, key: string): boolean {
168
+ return pendingRefreshes.has(fullKey(namespace, key));
169
+ }
@@ -0,0 +1,83 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import type { TNamespaceStat, TSmartCacheEntry } from "./smart-cache.types";
5
+
6
+ const CACHE_DIRECTORY = path.join(os.homedir(), ".simplemdg", "cache");
7
+
8
+ type TNamespaceFile = Record<string, TSmartCacheEntry<unknown>>;
9
+
10
+ function namespaceFilePath(namespace: string): string {
11
+ return path.join(CACHE_DIRECTORY, `${namespace}.json`);
12
+ }
13
+
14
+ export function getCacheDirectory(): string {
15
+ return CACHE_DIRECTORY;
16
+ }
17
+
18
+ async function readNamespaceFile(namespace: string): Promise<TNamespaceFile> {
19
+ const filePath = namespaceFilePath(namespace);
20
+
21
+ if (!(await fs.pathExists(filePath))) {
22
+ return {};
23
+ }
24
+
25
+ const parsed = await fs.readJson(filePath).catch(() => ({})) as TNamespaceFile;
26
+ return parsed && typeof parsed === "object" ? parsed : {};
27
+ }
28
+
29
+ async function writeNamespaceFile(namespace: string, file: TNamespaceFile): Promise<void> {
30
+ await fs.ensureDir(CACHE_DIRECTORY);
31
+ await fs.writeJson(namespaceFilePath(namespace), file, { spaces: 2 });
32
+ }
33
+
34
+ export async function readEntry<TData>(namespace: string, key: string): Promise<TSmartCacheEntry<TData> | undefined> {
35
+ const file = await readNamespaceFile(namespace);
36
+ return file[key] as TSmartCacheEntry<TData> | undefined;
37
+ }
38
+
39
+ export async function readAllEntries<TData>(namespace: string): Promise<Record<string, TSmartCacheEntry<TData>>> {
40
+ return await readNamespaceFile(namespace) as Record<string, TSmartCacheEntry<TData>>;
41
+ }
42
+
43
+ export async function writeEntry<TData>(namespace: string, key: string, entry: TSmartCacheEntry<TData>): Promise<void> {
44
+ const file = await readNamespaceFile(namespace);
45
+ file[key] = entry as TSmartCacheEntry<unknown>;
46
+ await writeNamespaceFile(namespace, file);
47
+ }
48
+
49
+ export async function removeEntry(namespace: string, key: string): Promise<boolean> {
50
+ const file = await readNamespaceFile(namespace);
51
+
52
+ if (!(key in file)) {
53
+ return false;
54
+ }
55
+
56
+ delete file[key];
57
+ await writeNamespaceFile(namespace, file);
58
+ return true;
59
+ }
60
+
61
+ export async function clearNamespace(namespace: string): Promise<void> {
62
+ await fs.remove(namespaceFilePath(namespace)).catch(() => undefined);
63
+ }
64
+
65
+ export async function statNamespace(namespace: string): Promise<TNamespaceStat> {
66
+ const filePath = namespaceFilePath(namespace);
67
+
68
+ if (!(await fs.pathExists(filePath))) {
69
+ return { namespace, count: 0, exists: false };
70
+ }
71
+
72
+ const file = await readNamespaceFile(namespace);
73
+ const entries = Object.values(file);
74
+ let lastUpdatedAt: string | undefined;
75
+
76
+ for (const entry of entries) {
77
+ if (!lastUpdatedAt || entry.updatedAt > lastUpdatedAt) {
78
+ lastUpdatedAt = entry.updatedAt;
79
+ }
80
+ }
81
+
82
+ return { namespace, count: entries.length, lastUpdatedAt, exists: true };
83
+ }
@@ -0,0 +1,97 @@
1
+ export * from "./smart-cache.types";
2
+ export { smartRead, refreshCache, computeCacheStatus, isRefreshPending } from "./smart-cache-manager";
3
+ export { onCacheEvent, emitCacheEvent } from "./smart-cache-events";
4
+ export {
5
+ clearNamespace,
6
+ statNamespace,
7
+ removeEntry,
8
+ readEntry,
9
+ writeEntry,
10
+ getCacheDirectory,
11
+ } from "./smart-cache-store";
12
+
13
+ /** Known cache namespaces with human labels (also drives `smdg cache status`). */
14
+ export const CACHE_NAMESPACES: Record<string, string> = {
15
+ "cf-regions": "CF regions",
16
+ "cf-targets": "CF targets",
17
+ "cf-orgs": "CF orgs",
18
+ "cf-spaces": "CF spaces",
19
+ "cf-apps": "CF apps",
20
+ "cf-env": "CF env (parsed, non-secret)",
21
+ "db-import-candidates": "DB import candidates",
22
+ "db-metadata": "DB metadata",
23
+ "gitlab-groups": "GitLab groups",
24
+ "gitlab-projects": "GitLab projects",
25
+ "cf-recent-targets": "CF recent targets",
26
+ "cf-favorite-targets": "CF favorite targets",
27
+ };
28
+
29
+ /** Namespace groups for `smdg cache clear|refresh <scope>`. */
30
+ export const CACHE_SCOPES: Record<string, string[]> = {
31
+ cf: ["cf-regions", "cf-targets", "cf-orgs", "cf-spaces", "cf-apps", "cf-env"],
32
+ gitlab: ["gitlab-groups", "gitlab-projects"],
33
+ db: ["db-import-candidates", "db-metadata"],
34
+ target: ["cf-targets", "cf-recent-targets", "cf-favorite-targets"],
35
+ all: Object.keys(CACHE_NAMESPACES),
36
+ };
37
+
38
+ function sanitize(value: string | undefined): string {
39
+ return (value ?? "").trim().replace(/::/g, ":") || "_";
40
+ }
41
+
42
+ export function buildCfRegionKey(region: string): string {
43
+ return sanitize(region);
44
+ }
45
+
46
+ export function buildCfOrgKey(region: string): string {
47
+ return sanitize(region);
48
+ }
49
+
50
+ export function buildCfSpaceKey(region: string, org: string): string {
51
+ return `${sanitize(region)}::${sanitize(org)}`;
52
+ }
53
+
54
+ export function buildCfTargetKey(region: string, org: string, space: string): string {
55
+ return `${sanitize(region)}::${sanitize(org)}::${sanitize(space)}`;
56
+ }
57
+
58
+ export function buildCfAppsKey(region: string, org: string, space: string): string {
59
+ return buildCfTargetKey(region, org, space);
60
+ }
61
+
62
+ export function buildCfEnvKey(region: string, org: string, space: string, appName: string): string {
63
+ return `${buildCfTargetKey(region, org, space)}::${sanitize(appName)}`;
64
+ }
65
+
66
+ export function buildDbImportCandidatesKey(region: string, org: string, space: string, appName: string): string {
67
+ return buildCfEnvKey(region, org, space, appName);
68
+ }
69
+
70
+ export function buildGitLabGroupsKey(baseUrl: string, userId?: string | number): string {
71
+ return `${sanitize(baseUrl)}::${sanitize(userId === undefined ? "" : String(userId))}`;
72
+ }
73
+
74
+ export function buildGitLabProjectsKey(baseUrl: string, groupId: string | number): string {
75
+ return `${sanitize(baseUrl)}::${sanitize(String(groupId))}`;
76
+ }
77
+
78
+ export function formatRelativeTime(iso: string | undefined): string {
79
+ if (!iso) {
80
+ return "never";
81
+ }
82
+
83
+ const deltaMs = Date.now() - new Date(iso).getTime();
84
+
85
+ if (deltaMs < 0) {
86
+ return "just now";
87
+ }
88
+
89
+ const seconds = Math.floor(deltaMs / 1000);
90
+ if (seconds < 45) return "just now";
91
+ const minutes = Math.floor(seconds / 60);
92
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
93
+ const hours = Math.floor(minutes / 60);
94
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
95
+ const days = Math.floor(hours / 24);
96
+ return `${days} day${days === 1 ? "" : "s"} ago`;
97
+ }
@@ -0,0 +1,79 @@
1
+ export type TCacheReadMode =
2
+ | "cache-first"
3
+ | "network-first"
4
+ | "cache-only"
5
+ | "network-only"
6
+ | "stale-while-revalidate";
7
+
8
+ export type TCacheStatus = "fresh" | "stale" | "expired";
9
+
10
+ export type TRefreshState = "idle" | "refreshing" | "success" | "failed";
11
+
12
+ export type TSmartCacheEntry<TData> = {
13
+ key: string;
14
+ data: TData;
15
+ createdAt: string;
16
+ updatedAt: string;
17
+ expiresAt?: string;
18
+ source: "cache" | "network";
19
+ status: TCacheStatus;
20
+ refreshState?: TRefreshState;
21
+ lastRefreshStartedAt?: string;
22
+ lastRefreshFinishedAt?: string;
23
+ lastRefreshError?: string;
24
+ ttlMs: number;
25
+ version: number;
26
+ };
27
+
28
+ export type TSmartCacheResult<TData> = {
29
+ data: TData;
30
+ fromCache: boolean;
31
+ cacheStatus: TCacheStatus | "missing";
32
+ isRefreshing: boolean;
33
+ updatedAt?: string;
34
+ refreshPromise?: Promise<TData>;
35
+ };
36
+
37
+ export type TSmartCacheReadOptions<TData> = {
38
+ namespace: string;
39
+ key: string;
40
+ ttlMs: number;
41
+ mode?: TCacheReadMode;
42
+ fetcher: () => Promise<TData>;
43
+ /** Revalidate in the background even when the cached entry is still fresh. */
44
+ revalidateWhenFresh?: boolean;
45
+ };
46
+
47
+ export type TCacheEventType =
48
+ | "cache-refresh-started"
49
+ | "cache-refresh-success"
50
+ | "cache-refresh-failed";
51
+
52
+ export type TCacheEvent = {
53
+ type: TCacheEventType;
54
+ key: string;
55
+ resource: string;
56
+ updatedAt?: string;
57
+ error?: string;
58
+ };
59
+
60
+ export type TNamespaceStat = {
61
+ namespace: string;
62
+ count: number;
63
+ lastUpdatedAt?: string;
64
+ exists: boolean;
65
+ };
66
+
67
+ /** Default TTLs (ms). BTP/GitLab structures rarely change second-to-second. */
68
+ export const DEFAULT_CACHE_TTL = {
69
+ cfRegions: 7 * 24 * 60 * 60 * 1000,
70
+ cfOrgs: 6 * 60 * 60 * 1000,
71
+ cfSpaces: 6 * 60 * 60 * 1000,
72
+ cfApps: 10 * 60 * 1000,
73
+ cfEnv: 5 * 60 * 1000,
74
+ dbImportCandidates: 10 * 60 * 1000,
75
+ gitlabGroups: 6 * 60 * 60 * 1000,
76
+ gitlabProjects: 30 * 60 * 1000,
77
+ dbMetadata: 10 * 60 * 1000,
78
+ dbConnections: Number.POSITIVE_INFINITY,
79
+ } as const;
@@ -0,0 +1,61 @@
1
+ import { readAllEntries, readEntry, removeEntry, writeEntry } from "../cache/smart-cache-store";
2
+ import { cfTargetKey } from "./cf-target.types";
3
+ import type { TCfTarget } from "./cf-target.types";
4
+ import type { TSmartCacheEntry } from "../cache/smart-cache.types";
5
+
6
+ const FAVORITES_NAMESPACE = "cf-favorite-targets";
7
+ const RECENT_NAMESPACE = "cf-recent-targets";
8
+ const MAX_RECENT = 20;
9
+
10
+ function toEntry(target: TCfTarget): TSmartCacheEntry<TCfTarget> {
11
+ const now = new Date().toISOString();
12
+ return {
13
+ key: cfTargetKey(target),
14
+ data: target,
15
+ createdAt: now,
16
+ updatedAt: target.lastUsedAt ?? now,
17
+ source: "cache",
18
+ status: "fresh",
19
+ ttlMs: Number.POSITIVE_INFINITY,
20
+ version: 1,
21
+ };
22
+ }
23
+
24
+ export async function listFavoriteTargets(): Promise<TCfTarget[]> {
25
+ const entries = await readAllEntries<TCfTarget>(FAVORITES_NAMESPACE);
26
+ return Object.values(entries).map((entry) => ({ ...entry.data, isFavorite: true }));
27
+ }
28
+
29
+ export async function isFavoriteTarget(target: TCfTarget): Promise<boolean> {
30
+ return Boolean(await readEntry<TCfTarget>(FAVORITES_NAMESPACE, cfTargetKey(target)));
31
+ }
32
+
33
+ export async function addFavoriteTarget(target: TCfTarget): Promise<void> {
34
+ await writeEntry(FAVORITES_NAMESPACE, cfTargetKey(target), toEntry({ ...target, isFavorite: true }));
35
+ }
36
+
37
+ export async function removeFavoriteTarget(target: TCfTarget): Promise<void> {
38
+ await removeEntry(FAVORITES_NAMESPACE, cfTargetKey(target));
39
+ }
40
+
41
+ export async function listRecentTargets(limit = 10): Promise<TCfTarget[]> {
42
+ const entries = await readAllEntries<TCfTarget>(RECENT_NAMESPACE);
43
+ return Object.values(entries)
44
+ .map((entry) => entry.data)
45
+ .sort((left, right) => String(right.lastUsedAt ?? "").localeCompare(String(left.lastUsedAt ?? "")))
46
+ .slice(0, limit);
47
+ }
48
+
49
+ export async function addRecentTarget(target: TCfTarget): Promise<void> {
50
+ const recent: TCfTarget = { ...target, lastUsedAt: new Date().toISOString() };
51
+ await writeEntry(RECENT_NAMESPACE, cfTargetKey(recent), toEntry(recent));
52
+
53
+ const entries = await readAllEntries<TCfTarget>(RECENT_NAMESPACE);
54
+ const sorted = Object.values(entries)
55
+ .map((entry) => entry.data)
56
+ .sort((left, right) => String(right.lastUsedAt ?? "").localeCompare(String(left.lastUsedAt ?? "")));
57
+
58
+ for (const stale of sorted.slice(MAX_RECENT)) {
59
+ await removeEntry(RECENT_NAMESPACE, cfTargetKey(stale)).catch(() => undefined);
60
+ }
61
+ }
@@ -0,0 +1,17 @@
1
+ export type TCfTarget = {
2
+ region: string;
3
+ apiEndpoint: string;
4
+ org: string;
5
+ space: string;
6
+ isFavorite?: boolean;
7
+ lastUsedAt?: string;
8
+ lastRefreshedAt?: string;
9
+ };
10
+
11
+ export function cfTargetKey(target: Pick<TCfTarget, "region" | "org" | "space">): string {
12
+ return `${target.region}::${target.org}::${target.space || ""}`;
13
+ }
14
+
15
+ export function cfTargetLabel(target: Pick<TCfTarget, "region" | "org" | "space">): string {
16
+ return `${target.region} / ${target.org}${target.space ? ` / ${target.space}` : ""}`;
17
+ }