@markjaquith/agency 2.70.0 → 2.71.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.
- package/README.md +15 -0
- package/cli-main.ts +74 -3
- package/package.json +1 -1
- package/src/cli-parser.ts +11 -0
- package/src/cli.test.ts +40 -0
- package/src/usage-log.test.ts +94 -0
- package/src/usage-log.ts +143 -0
package/README.md
CHANGED
|
@@ -21,6 +21,21 @@ bun install -g @markjaquith/agency
|
|
|
21
21
|
|
|
22
22
|
For development, run `bun link` from this repository.
|
|
23
23
|
|
|
24
|
+
## Local Usage Logging
|
|
25
|
+
|
|
26
|
+
Agency records privacy-safe CLI usage events locally so command journeys,
|
|
27
|
+
failures, and flag adoption can be analyzed. Events are stored in SQLite at
|
|
28
|
+
`$XDG_STATE_HOME/agency/usage.sqlite3` (or
|
|
29
|
+
`~/.local/state/agency/usage.sqlite3`) and retained for 90 days by default.
|
|
30
|
+
Each event contains the normalized command path, flag names, timing, outcome,
|
|
31
|
+
Agency version, and ordered `AGENCY_SESSION_ID` correlation. Raw arguments,
|
|
32
|
+
flag values, free-form input, and the current directory are never recorded.
|
|
33
|
+
|
|
34
|
+
Export events as JSON Lines with `agency usage export`. Set
|
|
35
|
+
`AGENCY_NO_USAGE_LOG=1` to opt out, `AGENCY_USAGE_RETENTION_DAYS` to change
|
|
36
|
+
retention, or `AGENCY_USAGE_DB` to select a different database path. Logging is
|
|
37
|
+
best effort and never changes command output or exit behavior.
|
|
38
|
+
|
|
24
39
|
## Core Model
|
|
25
40
|
|
|
26
41
|
- A **workbase** is the root containing durable documents and local repository
|
package/cli-main.ts
CHANGED
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
successEnvelope,
|
|
68
68
|
writeEnvelope,
|
|
69
69
|
} from "./src/protocol"
|
|
70
|
+
import { exportUsageEvents, recordUsageEvent } from "./src/usage-log"
|
|
70
71
|
|
|
71
72
|
// Create CLI layer with all services
|
|
72
73
|
const CliLayer = Layer.mergeAll(
|
|
@@ -176,6 +177,12 @@ const VERSION = packageJson.version
|
|
|
176
177
|
|
|
177
178
|
// Define commands
|
|
178
179
|
const commands: Record<string, Command> = {
|
|
180
|
+
usage: {
|
|
181
|
+
run: async () => {
|
|
182
|
+
for (const event of await exportUsageEvents())
|
|
183
|
+
console.log(JSON.stringify(event))
|
|
184
|
+
},
|
|
185
|
+
},
|
|
179
186
|
act: {
|
|
180
187
|
run: async (args: string[], options: Record<string, any>) => {
|
|
181
188
|
if (options.help) return console.log(actHelp)
|
|
@@ -785,6 +792,7 @@ Commands:
|
|
|
785
792
|
init [path] Initialize an Agency workbase
|
|
786
793
|
workbase <subcommand> Manage registered workbases
|
|
787
794
|
integration <command> Inspect or sync managed integration files
|
|
795
|
+
usage export Export local usage events as JSON Lines
|
|
788
796
|
epic <subcommand> Manage epics
|
|
789
797
|
phase <subcommand> Manage task phases
|
|
790
798
|
claim <task> [phase] Claim an execution unit
|
|
@@ -830,9 +838,30 @@ const machineMode = process.argv
|
|
|
830
838
|
.slice(2)
|
|
831
839
|
.some((argument) => argument === "--json" || argument === "--jsonl")
|
|
832
840
|
|
|
841
|
+
const invocationStartedAt = performance.now()
|
|
842
|
+
const rawArguments = process.argv.slice(2)
|
|
843
|
+
let usageCommandPath = "invalid"
|
|
844
|
+
let usageFlagNames = rawArguments
|
|
845
|
+
.filter((argument) => argument.startsWith("--"))
|
|
846
|
+
.map((argument) => argument.slice(2).split("=", 1)[0]!)
|
|
847
|
+
|
|
833
848
|
try {
|
|
834
|
-
const
|
|
835
|
-
|
|
849
|
+
const {
|
|
850
|
+
commandName,
|
|
851
|
+
args: commandArgs,
|
|
852
|
+
passthrough,
|
|
853
|
+
values,
|
|
854
|
+
} = parseCli(rawArguments)
|
|
855
|
+
usageCommandPath =
|
|
856
|
+
[commandName, commandArgs[0]]
|
|
857
|
+
.filter(
|
|
858
|
+
(part): part is string =>
|
|
859
|
+
typeof part === "string" && part.length > 0 && !part.startsWith("-"),
|
|
860
|
+
)
|
|
861
|
+
.join("/") || "root"
|
|
862
|
+
usageFlagNames = Object.entries(values)
|
|
863
|
+
.filter(([, value]) => value !== undefined && value !== false)
|
|
864
|
+
.map(([name]) => name)
|
|
836
865
|
|
|
837
866
|
// Handle global flags
|
|
838
867
|
if (values.version) {
|
|
@@ -841,6 +870,16 @@ try {
|
|
|
841
870
|
} else {
|
|
842
871
|
console.log(`v${VERSION}`)
|
|
843
872
|
}
|
|
873
|
+
await recordUsageEvent(
|
|
874
|
+
{
|
|
875
|
+
commandPath: "version",
|
|
876
|
+
flagNames: usageFlagNames,
|
|
877
|
+
durationMs: performance.now() - invocationStartedAt,
|
|
878
|
+
outcome: "success",
|
|
879
|
+
exitStatus: 0,
|
|
880
|
+
},
|
|
881
|
+
VERSION,
|
|
882
|
+
)
|
|
844
883
|
process.exit(0)
|
|
845
884
|
}
|
|
846
885
|
|
|
@@ -848,7 +887,18 @@ try {
|
|
|
848
887
|
// Show help if no command
|
|
849
888
|
if (!commandName) {
|
|
850
889
|
showMainHelp()
|
|
851
|
-
|
|
890
|
+
const exitStatus = values.help ? 0 : 1
|
|
891
|
+
await recordUsageEvent(
|
|
892
|
+
{
|
|
893
|
+
commandPath: "help",
|
|
894
|
+
flagNames: usageFlagNames,
|
|
895
|
+
durationMs: performance.now() - invocationStartedAt,
|
|
896
|
+
outcome: exitStatus === 0 ? "success" : "failure",
|
|
897
|
+
exitStatus,
|
|
898
|
+
},
|
|
899
|
+
VERSION,
|
|
900
|
+
)
|
|
901
|
+
process.exit(exitStatus)
|
|
852
902
|
}
|
|
853
903
|
|
|
854
904
|
const command = commands[commandName]!
|
|
@@ -880,7 +930,28 @@ try {
|
|
|
880
930
|
passthrough,
|
|
881
931
|
})
|
|
882
932
|
}
|
|
933
|
+
const exitStatus = Number(process.exitCode ?? 0)
|
|
934
|
+
await recordUsageEvent(
|
|
935
|
+
{
|
|
936
|
+
commandPath: usageCommandPath,
|
|
937
|
+
flagNames: usageFlagNames,
|
|
938
|
+
durationMs: performance.now() - invocationStartedAt,
|
|
939
|
+
outcome: exitStatus === 0 ? "success" : "failure",
|
|
940
|
+
exitStatus,
|
|
941
|
+
},
|
|
942
|
+
VERSION,
|
|
943
|
+
)
|
|
883
944
|
} catch (error) {
|
|
945
|
+
await recordUsageEvent(
|
|
946
|
+
{
|
|
947
|
+
commandPath: usageCommandPath,
|
|
948
|
+
flagNames: usageFlagNames,
|
|
949
|
+
durationMs: performance.now() - invocationStartedAt,
|
|
950
|
+
outcome: "failure",
|
|
951
|
+
exitStatus: 1,
|
|
952
|
+
},
|
|
953
|
+
VERSION,
|
|
954
|
+
)
|
|
884
955
|
if (machineMode) {
|
|
885
956
|
writeEnvelope(errorEnvelope(error))
|
|
886
957
|
process.exit(1)
|
package/package.json
CHANGED
package/src/cli-parser.ts
CHANGED
|
@@ -136,6 +136,17 @@ const nonPrCompletionOptions = {
|
|
|
136
136
|
} satisfies OptionConfig
|
|
137
137
|
|
|
138
138
|
const commands = {
|
|
139
|
+
usage: {
|
|
140
|
+
usage: "agency usage export",
|
|
141
|
+
options: commonOptions,
|
|
142
|
+
subcommands: {
|
|
143
|
+
export: {
|
|
144
|
+
usage: "agency usage export",
|
|
145
|
+
minArgs: 0,
|
|
146
|
+
maxArgs: 0,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
139
150
|
init: {
|
|
140
151
|
usage: "agency init [path] [--json]",
|
|
141
152
|
options: outputOptions,
|
package/src/cli.test.ts
CHANGED
|
@@ -36,6 +36,8 @@ async function runCli(
|
|
|
36
36
|
env: {
|
|
37
37
|
...process.env,
|
|
38
38
|
XDG_CONFIG_HOME: isolatedConfigHome,
|
|
39
|
+
XDG_STATE_HOME: isolatedConfigHome,
|
|
40
|
+
AGENCY_NO_USAGE_LOG: "1",
|
|
39
41
|
...env,
|
|
40
42
|
},
|
|
41
43
|
stdout: "pipe",
|
|
@@ -170,6 +172,44 @@ describe("CLI", () => {
|
|
|
170
172
|
expect(taggedError.stderr).not.toContain("An error has occurred")
|
|
171
173
|
})
|
|
172
174
|
|
|
175
|
+
test("records successful and failed invocations without argument values", async () => {
|
|
176
|
+
const state = await createTempDir()
|
|
177
|
+
tempDirs.push(state)
|
|
178
|
+
const env = {
|
|
179
|
+
XDG_STATE_HOME: state,
|
|
180
|
+
AGENCY_SESSION_ID: "cli-session",
|
|
181
|
+
AGENCY_NO_USAGE_LOG: "0",
|
|
182
|
+
}
|
|
183
|
+
expect((await runCli(["--version"], projectRoot, env)).exitCode).toBe(0)
|
|
184
|
+
expect(
|
|
185
|
+
(await runCli(["unknown", "--cwd", "/private/value"], projectRoot, env))
|
|
186
|
+
.exitCode,
|
|
187
|
+
).toBe(1)
|
|
188
|
+
|
|
189
|
+
const exported = await runCli(["usage", "export"], projectRoot, env)
|
|
190
|
+
expect(exported).toMatchObject({ exitCode: 0, stderr: "" })
|
|
191
|
+
const events = exported.stdout
|
|
192
|
+
.trim()
|
|
193
|
+
.split("\n")
|
|
194
|
+
.map((line) => JSON.parse(line))
|
|
195
|
+
expect(events).toEqual([
|
|
196
|
+
expect.objectContaining({
|
|
197
|
+
sessionId: "cli-session",
|
|
198
|
+
sessionSequence: 1,
|
|
199
|
+
commandPath: "version",
|
|
200
|
+
flagNames: ["version"],
|
|
201
|
+
outcome: "success",
|
|
202
|
+
}),
|
|
203
|
+
expect.objectContaining({
|
|
204
|
+
sessionSequence: 2,
|
|
205
|
+
commandPath: "invalid",
|
|
206
|
+
flagNames: ["cwd"],
|
|
207
|
+
outcome: "failure",
|
|
208
|
+
}),
|
|
209
|
+
])
|
|
210
|
+
expect(exported.stdout).not.toContain("/private/value")
|
|
211
|
+
})
|
|
212
|
+
|
|
173
213
|
test("coordinates claims through revision-guarded machine commands", async () => {
|
|
174
214
|
const root = await createTempDir()
|
|
175
215
|
tempDirs.push(root)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { rm } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { cleanupTempDir, createTempDir } from "./test-utils"
|
|
5
|
+
import { exportUsageEvents, recordUsageEvent } from "./usage-log"
|
|
6
|
+
|
|
7
|
+
describe("usage logging", () => {
|
|
8
|
+
const tempDirs: string[] = []
|
|
9
|
+
|
|
10
|
+
afterEach(() => Promise.all(tempDirs.splice(0).map(cleanupTempDir)))
|
|
11
|
+
|
|
12
|
+
test("stores versioned privacy-safe events in session order", async () => {
|
|
13
|
+
const state = await createTempDir()
|
|
14
|
+
tempDirs.push(state)
|
|
15
|
+
const env = {
|
|
16
|
+
XDG_STATE_HOME: state,
|
|
17
|
+
AGENCY_SESSION_ID: "session-1",
|
|
18
|
+
} as NodeJS.ProcessEnv
|
|
19
|
+
for (const commandPath of ["worktree/prepare", "context"]) {
|
|
20
|
+
await recordUsageEvent(
|
|
21
|
+
{
|
|
22
|
+
commandPath,
|
|
23
|
+
flagNames: ["json", "task", "json"],
|
|
24
|
+
durationMs: 12.4,
|
|
25
|
+
outcome: "success",
|
|
26
|
+
exitStatus: 0,
|
|
27
|
+
},
|
|
28
|
+
"1.2.3",
|
|
29
|
+
env,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
|
|
34
|
+
true,
|
|
35
|
+
)
|
|
36
|
+
expect(await exportUsageEvents(env)).toEqual([
|
|
37
|
+
expect.objectContaining({
|
|
38
|
+
version: 1,
|
|
39
|
+
sessionId: "session-1",
|
|
40
|
+
sessionSequence: 1,
|
|
41
|
+
agencyVersion: "1.2.3",
|
|
42
|
+
commandPath: "worktree/prepare",
|
|
43
|
+
flagNames: ["json", "task"],
|
|
44
|
+
durationMs: 12,
|
|
45
|
+
outcome: "success",
|
|
46
|
+
exitStatus: 0,
|
|
47
|
+
}),
|
|
48
|
+
expect.objectContaining({
|
|
49
|
+
sessionSequence: 2,
|
|
50
|
+
commandPath: "context",
|
|
51
|
+
}),
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("supports opt-out and ignores unavailable storage", async () => {
|
|
56
|
+
const state = await createTempDir()
|
|
57
|
+
tempDirs.push(state)
|
|
58
|
+
const disabled = {
|
|
59
|
+
XDG_STATE_HOME: state,
|
|
60
|
+
AGENCY_NO_USAGE_LOG: "true",
|
|
61
|
+
} as NodeJS.ProcessEnv
|
|
62
|
+
await recordUsageEvent(
|
|
63
|
+
{
|
|
64
|
+
commandPath: "status",
|
|
65
|
+
flagNames: [],
|
|
66
|
+
durationMs: 1,
|
|
67
|
+
outcome: "failure",
|
|
68
|
+
exitStatus: 1,
|
|
69
|
+
},
|
|
70
|
+
"1.2.3",
|
|
71
|
+
disabled,
|
|
72
|
+
)
|
|
73
|
+
expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
|
|
74
|
+
false,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const blocked = join(state, "blocked")
|
|
78
|
+
await Bun.write(blocked, "not a directory")
|
|
79
|
+
await expect(
|
|
80
|
+
recordUsageEvent(
|
|
81
|
+
{
|
|
82
|
+
commandPath: "status",
|
|
83
|
+
flagNames: [],
|
|
84
|
+
durationMs: 1,
|
|
85
|
+
outcome: "failure",
|
|
86
|
+
exitStatus: 1,
|
|
87
|
+
},
|
|
88
|
+
"1.2.3",
|
|
89
|
+
{ AGENCY_USAGE_DB: join(blocked, "usage.sqlite3") },
|
|
90
|
+
),
|
|
91
|
+
).resolves.toBeUndefined()
|
|
92
|
+
await rm(blocked)
|
|
93
|
+
})
|
|
94
|
+
})
|
package/src/usage-log.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { mkdir } from "node:fs/promises"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
|
|
5
|
+
const USAGE_EVENT_VERSION = 1 as const
|
|
6
|
+
const DEFAULT_RETENTION_DAYS = 90
|
|
7
|
+
|
|
8
|
+
export interface UsageEvent {
|
|
9
|
+
readonly commandPath: string
|
|
10
|
+
readonly flagNames: readonly string[]
|
|
11
|
+
readonly durationMs: number
|
|
12
|
+
readonly exitStatus: number
|
|
13
|
+
readonly outcome: "success" | "failure"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const stateDirectory = (env: NodeJS.ProcessEnv) =>
|
|
17
|
+
env.XDG_STATE_HOME ?? join(env.HOME ?? ".", ".local", "state")
|
|
18
|
+
|
|
19
|
+
const usageDatabasePath = (env: NodeJS.ProcessEnv = process.env) =>
|
|
20
|
+
env.AGENCY_USAGE_DB ?? join(stateDirectory(env), "agency", "usage.sqlite3")
|
|
21
|
+
|
|
22
|
+
const enabled = (env: NodeJS.ProcessEnv) =>
|
|
23
|
+
!["1", "true", "yes"].includes((env.AGENCY_NO_USAGE_LOG ?? "").toLowerCase())
|
|
24
|
+
|
|
25
|
+
const retentionDays = (env: NodeJS.ProcessEnv) => {
|
|
26
|
+
const value = Number.parseInt(env.AGENCY_USAGE_RETENTION_DAYS ?? "", 10)
|
|
27
|
+
return Number.isSafeInteger(value) && value >= 0
|
|
28
|
+
? value
|
|
29
|
+
: DEFAULT_RETENTION_DAYS
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const openDatabase = async (env: NodeJS.ProcessEnv) => {
|
|
33
|
+
const path = usageDatabasePath(env)
|
|
34
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
35
|
+
const database = new Database(path, { create: true, strict: true })
|
|
36
|
+
database.run("PRAGMA journal_mode = WAL")
|
|
37
|
+
database.run("PRAGMA busy_timeout = 1000")
|
|
38
|
+
database.run(`
|
|
39
|
+
CREATE TABLE IF NOT EXISTS usage_events (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
event_version INTEGER NOT NULL,
|
|
42
|
+
session_id TEXT NOT NULL,
|
|
43
|
+
session_sequence INTEGER NOT NULL,
|
|
44
|
+
occurred_at TEXT NOT NULL,
|
|
45
|
+
agency_version TEXT NOT NULL,
|
|
46
|
+
command_path TEXT NOT NULL,
|
|
47
|
+
flag_names TEXT NOT NULL,
|
|
48
|
+
duration_ms INTEGER NOT NULL,
|
|
49
|
+
outcome TEXT NOT NULL,
|
|
50
|
+
exit_status INTEGER NOT NULL
|
|
51
|
+
)
|
|
52
|
+
`)
|
|
53
|
+
database.run(
|
|
54
|
+
"CREATE INDEX IF NOT EXISTS usage_events_session ON usage_events(session_id, session_sequence)",
|
|
55
|
+
)
|
|
56
|
+
database.run(
|
|
57
|
+
"CREATE INDEX IF NOT EXISTS usage_events_command ON usage_events(command_path, occurred_at)",
|
|
58
|
+
)
|
|
59
|
+
return database
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function recordUsageEvent(
|
|
63
|
+
event: UsageEvent,
|
|
64
|
+
agencyVersion: string,
|
|
65
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
if (!enabled(env)) return
|
|
68
|
+
let database: Database | undefined
|
|
69
|
+
try {
|
|
70
|
+
database = await openDatabase(env)
|
|
71
|
+
const sessionId = env.AGENCY_SESSION_ID || `process-${process.pid}`
|
|
72
|
+
database
|
|
73
|
+
.query(`
|
|
74
|
+
INSERT INTO usage_events (
|
|
75
|
+
event_version, session_id, session_sequence, occurred_at,
|
|
76
|
+
agency_version, command_path, flag_names, duration_ms,
|
|
77
|
+
outcome, exit_status
|
|
78
|
+
) VALUES (
|
|
79
|
+
?, ?,
|
|
80
|
+
(SELECT COALESCE(MAX(session_sequence), 0) + 1 FROM usage_events WHERE session_id = ?),
|
|
81
|
+
?, ?, ?, ?, ?, ?, ?
|
|
82
|
+
)
|
|
83
|
+
`)
|
|
84
|
+
.run(
|
|
85
|
+
USAGE_EVENT_VERSION,
|
|
86
|
+
sessionId,
|
|
87
|
+
sessionId,
|
|
88
|
+
new Date().toISOString(),
|
|
89
|
+
agencyVersion,
|
|
90
|
+
event.commandPath,
|
|
91
|
+
JSON.stringify([...new Set(event.flagNames)].sort()),
|
|
92
|
+
Math.max(0, Math.round(event.durationMs)),
|
|
93
|
+
event.outcome,
|
|
94
|
+
event.exitStatus,
|
|
95
|
+
)
|
|
96
|
+
if (Math.random() < 0.01) {
|
|
97
|
+
const days = retentionDays(env)
|
|
98
|
+
database
|
|
99
|
+
.query(
|
|
100
|
+
"DELETE FROM usage_events WHERE occurred_at < datetime('now', ?)",
|
|
101
|
+
)
|
|
102
|
+
.run(`-${days} days`)
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Usage logging must never affect command behavior.
|
|
106
|
+
} finally {
|
|
107
|
+
database?.close()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function exportUsageEvents(
|
|
112
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
113
|
+
): Promise<readonly Record<string, unknown>[]> {
|
|
114
|
+
if (!enabled(env)) return []
|
|
115
|
+
let database: Database | undefined
|
|
116
|
+
try {
|
|
117
|
+
database = await openDatabase(env)
|
|
118
|
+
const rows = database
|
|
119
|
+
.query(`
|
|
120
|
+
SELECT event_version, session_id, session_sequence, occurred_at,
|
|
121
|
+
agency_version, command_path, flag_names, duration_ms,
|
|
122
|
+
outcome, exit_status
|
|
123
|
+
FROM usage_events ORDER BY occurred_at, id
|
|
124
|
+
`)
|
|
125
|
+
.all() as Record<string, string | number>[]
|
|
126
|
+
return rows.map((row) => ({
|
|
127
|
+
version: row.event_version,
|
|
128
|
+
sessionId: row.session_id,
|
|
129
|
+
sessionSequence: row.session_sequence,
|
|
130
|
+
occurredAt: row.occurred_at,
|
|
131
|
+
agencyVersion: row.agency_version,
|
|
132
|
+
commandPath: row.command_path,
|
|
133
|
+
flagNames: JSON.parse(String(row.flag_names)),
|
|
134
|
+
durationMs: row.duration_ms,
|
|
135
|
+
outcome: row.outcome,
|
|
136
|
+
exitStatus: row.exit_status,
|
|
137
|
+
}))
|
|
138
|
+
} catch {
|
|
139
|
+
return []
|
|
140
|
+
} finally {
|
|
141
|
+
database?.close()
|
|
142
|
+
}
|
|
143
|
+
}
|