Skip to content

Commit 862ed89

Browse files
authored
Merge pull request #368 from bsv-blockchain/feature/kvstore-tags
Feature/kvstore tags
2 parents 06772ed + c5446c2 commit 862ed89

File tree

8 files changed

+202
-17
lines changed

8 files changed

+202
-17
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format
55
## Table of Contents
66

77
- [Unreleased](#unreleased)
8+
- [1.8.8 - 2025-10-22](#188---2025-10-22)
89
- [1.8.7 - 2025-10-22](#187---2025-10-22)
910
- [1.8.5 - 2025-10-21](#185---2025-10-21)
1011
- [1.8.4 - 2025-10-20](#184---2025-10-20)
@@ -167,6 +168,14 @@ All notable changes to this project will be documented in this file. The format
167168

168169
---
169170

171+
### [1.8.8] - 2025-10-22
172+
173+
### Added
174+
175+
- **GlobalKVStore**: Support for tags in set/get operations
176+
177+
---
178+
170179
### [1.8.7] - 2025-10-22
171180

172181
### Fixed

docs/reference/kvstore.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export interface KVStoreEntry {
169169
value: string;
170170
controller: PubKeyHex;
171171
protocolID: WalletProtocol;
172+
tags?: string[];
172173
token?: KVStoreToken;
173174
history?: string[];
174175
}
@@ -246,6 +247,7 @@ export interface KVStoreQuery {
246247
key?: string;
247248
controller?: PubKeyHex;
248249
protocolID?: WalletProtocol;
250+
tags?: string[];
249251
limit?: number;
250252
skip?: number;
251253
sortOrder?: "asc" | "desc";
@@ -279,6 +281,7 @@ export interface KVStoreSetOptions {
279281
tokenSetDescription?: string;
280282
tokenUpdateDescription?: string;
281283
tokenAmount?: number;
284+
tags?: string[];
282285
}
283286
```
284287

@@ -577,7 +580,8 @@ kvProtocol = {
577580
key: 1,
578581
value: 2,
579582
controller: 3,
580-
signature: 4
583+
tags: 4,
584+
signature: 5
581585
}
582586
```
583587

@@ -595,7 +599,10 @@ kvStoreInterpreter: InterpreterFunction<string, KVContext> = async (transaction:
595599
if (ctx == null || ctx.key == null)
596600
return undefined;
597601
const decoded = PushDrop.decode(output.lockingScript);
598-
if (decoded.fields.length !== Object.keys(kvProtocol).length)
602+
const expectedFieldCount = Object.keys(kvProtocol).length;
603+
const hasTagsField = decoded.fields.length === expectedFieldCount;
604+
const isOldFormat = decoded.fields.length === expectedFieldCount - 1;
605+
if (!isOldFormat && !hasTagsField)
599606
return undefined;
600607
const key = Utils.toUTF8(decoded.fields[kvProtocol.key]);
601608
const protocolID = Utils.toUTF8(decoded.fields[kvProtocol.protocolID]);

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bsv/sdk",
3-
"version": "1.8.7",
3+
"version": "1.8.8",
44
"type": "module",
55
"description": "BSV Blockchain Software Development Kit",
66
"main": "dist/cjs/mod.js",

src/kvstore/GlobalKVStore.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ export class GlobalKVStore {
138138
const tokenSetDescription = (options.tokenSetDescription != null && options.tokenSetDescription !== '') ? options.tokenSetDescription : `Create KVStore value for ${key}`
139139
const tokenUpdateDescription = (options.tokenUpdateDescription != null && options.tokenUpdateDescription !== '') ? options.tokenUpdateDescription : `Update KVStore value for ${key}`
140140
const tokenAmount = options.tokenAmount ?? this.config.tokenAmount
141+
const tags = options.tags ?? []
141142

142143
try {
143144
// Check for existing token to spend
@@ -146,13 +147,20 @@ export class GlobalKVStore {
146147

147148
// Create PushDrop locking script
148149
const pushdrop = new PushDrop(this.wallet, this.config.originator)
150+
const lockingScriptFields = [
151+
Utils.toArray(JSON.stringify(protocolID), 'utf8'),
152+
Utils.toArray(key, 'utf8'),
153+
Utils.toArray(value, 'utf8'),
154+
Utils.toArray(controller, 'hex')
155+
]
156+
157+
// Add tags as optional 5th field for backwards compatibility
158+
if (tags.length > 0) {
159+
lockingScriptFields.push(Utils.toArray(JSON.stringify(tags), 'utf8'))
160+
}
161+
149162
const lockingScript = await pushdrop.lock(
150-
[
151-
Utils.toArray(JSON.stringify(protocolID), 'utf8'),
152-
Utils.toArray(key, 'utf8'),
153-
Utils.toArray(value, 'utf8'),
154-
Utils.toArray(controller, 'hex')
155-
],
163+
lockingScriptFields,
156164
protocolID ?? this.config.protocolID as WalletProtocol,
157165
Utils.toUTF8(Utils.toArray(key, 'utf8')),
158166
'anyone',
@@ -409,7 +417,12 @@ export class GlobalKVStore {
409417
const output = tx.outputs[result.outputIndex]
410418
const decoded = PushDrop.decode(output.lockingScript)
411419

412-
if (decoded.fields.length !== 5) {
420+
// Support backwards compatibility: old format without tags, new format with tags
421+
const expectedFieldCount = Object.keys(kvProtocol).length
422+
const hasTagsField = decoded.fields.length === expectedFieldCount
423+
const isOldFormat = decoded.fields.length === expectedFieldCount - 1
424+
425+
if (!isOldFormat && !hasTagsField) {
413426
continue
414427
}
415428

@@ -429,11 +442,23 @@ export class GlobalKVStore {
429442
continue
430443
}
431444

445+
// Extract tags if present (backwards compatible)
446+
let tags: string[] | undefined
447+
if (hasTagsField && decoded.fields[kvProtocol.tags] != null) {
448+
try {
449+
tags = JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.tags]))
450+
} catch (e) {
451+
// If tags parsing fails, continue without tags
452+
tags = undefined
453+
}
454+
}
455+
432456
const entry: KVStoreEntry = {
433457
key: Utils.toUTF8(decoded.fields[kvProtocol.key]),
434458
value: Utils.toUTF8(decoded.fields[kvProtocol.value]),
435459
controller: Utils.toHex(decoded.fields[kvProtocol.controller]),
436-
protocolID: JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.protocolID]))
460+
protocolID: JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.protocolID])),
461+
tags
437462
}
438463

439464
if (options.includeToken === true) {

src/kvstore/__tests/GlobalKVStore.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,46 @@ describe('GlobalKVStore', () => {
287287
})
288288
})
289289

290+
it('returns entry with tags when token includes tags field', async () => {
291+
primeResolverWithOneOutput(mockResolver)
292+
293+
const originalDecode = (MockPushDrop as any).decode
294+
;(MockPushDrop as any).decode = jest.fn().mockReturnValue({
295+
fields: [
296+
Array.from(Buffer.from(JSON.stringify([1, 'kvstore']))), // protocolID
297+
Array.from(Buffer.from(TEST_KEY)), // key
298+
Array.from(Buffer.from(TEST_VALUE)), // value
299+
Array.from(Buffer.from(TEST_CONTROLLER, 'hex')), // controller
300+
// tags field as JSON string so Utils.toUTF8 returns it directly
301+
'["alpha","beta"]',
302+
Array.from(Buffer.from('signature')) // signature
303+
]
304+
})
305+
306+
const result = await kvStore.get({ key: TEST_KEY })
307+
308+
expect(Array.isArray(result)).toBe(true)
309+
expect(result).toHaveLength(1)
310+
if (Array.isArray(result) && result.length > 0) {
311+
expect(result[0].tags).toEqual(['alpha', 'beta'])
312+
}
313+
314+
;(MockPushDrop as any).decode = originalDecode
315+
})
316+
317+
it('omits tags when token is in old-format (no tags field)', async () => {
318+
primeResolverWithOneOutput(mockResolver)
319+
320+
// primePushDropDecodeToValidValue() already sets old-format (no tags)
321+
const result = await kvStore.get({ key: TEST_KEY })
322+
323+
expect(Array.isArray(result)).toBe(true)
324+
expect(result).toHaveLength(1)
325+
if (Array.isArray(result) && result.length > 0) {
326+
expect(result[0].tags).toBeUndefined()
327+
}
328+
})
329+
290330
it('returns entry with history when history=true', async () => {
291331
primeResolverWithOneOutput(mockResolver)
292332
mockHistorian.buildHistory.mockResolvedValue(['oldValue', TEST_VALUE])
@@ -320,6 +360,67 @@ describe('GlobalKVStore', () => {
320360
})
321361
})
322362

363+
it('forwards tags-only queries to the resolver', async () => {
364+
primeResolverEmpty(mockResolver)
365+
366+
const tags = ['group:music', 'env:prod']
367+
const result = await kvStore.get({ tags })
368+
369+
expect(Array.isArray(result)).toBe(true)
370+
expect(mockResolver.query).toHaveBeenCalledWith({
371+
service: 'ls_kvstore',
372+
query: expect.objectContaining({ tags })
373+
})
374+
})
375+
376+
it('forwards tagQueryMode "all" to the resolver (default)', async () => {
377+
primeResolverEmpty(mockResolver)
378+
379+
const tags = ['music', 'rock']
380+
const result = await kvStore.get({ tags, tagQueryMode: 'all' })
381+
382+
expect(Array.isArray(result)).toBe(true)
383+
expect(mockResolver.query).toHaveBeenCalledWith({
384+
service: 'ls_kvstore',
385+
query: expect.objectContaining({
386+
tags,
387+
tagQueryMode: 'all'
388+
})
389+
})
390+
})
391+
392+
it('forwards tagQueryMode "any" to the resolver', async () => {
393+
primeResolverEmpty(mockResolver)
394+
395+
const tags = ['music', 'jazz']
396+
const result = await kvStore.get({ tags, tagQueryMode: 'any' })
397+
398+
expect(Array.isArray(result)).toBe(true)
399+
expect(mockResolver.query).toHaveBeenCalledWith({
400+
service: 'ls_kvstore',
401+
query: expect.objectContaining({
402+
tags,
403+
tagQueryMode: 'any'
404+
})
405+
})
406+
})
407+
408+
it('defaults to tagQueryMode "all" when not specified', async () => {
409+
primeResolverEmpty(mockResolver)
410+
411+
const tags = ['category:news']
412+
const result = await kvStore.get({ tags })
413+
414+
expect(Array.isArray(result)).toBe(true)
415+
expect(mockResolver.query).toHaveBeenCalledWith({
416+
service: 'ls_kvstore',
417+
query: expect.objectContaining({ tags })
418+
})
419+
// Verify tagQueryMode is not explicitly set (will default to 'all' on server side)
420+
const call = (mockResolver.query as jest.Mock).mock.calls[0][0]
421+
expect(call.query.tagQueryMode).toBeUndefined()
422+
})
423+
323424
it('includes token data when includeToken=true for key queries', async () => {
324425
primeResolverWithOneOutput(mockResolver)
325426

@@ -644,6 +745,34 @@ describe('GlobalKVStore', () => {
644745
expect(mockBroadcaster.broadcast).toHaveBeenCalled()
645746
})
646747

748+
it('includes tags field in locking script when options.tags provided', async () => {
749+
primeResolverEmpty(mockResolver)
750+
751+
// Override PushDrop to capture the instance used within set()
752+
const originalImpl = (MockPushDrop as any).mockImplementation
753+
const mockLockingScript = { toHex: () => 'mockLockingScriptHex' }
754+
const localPushDrop = {
755+
lock: jest.fn().mockResolvedValue(mockLockingScript),
756+
unlock: jest.fn().mockReturnValue({
757+
sign: jest.fn().mockResolvedValue({ toHex: () => 'mockUnlockingScript' })
758+
})
759+
}
760+
;(MockPushDrop as any).mockImplementation(() => localPushDrop as any)
761+
762+
const providedTags = ['primary', 'news']
763+
await kvStore.set(TEST_KEY, TEST_VALUE, { tags: providedTags })
764+
765+
// Validate PushDrop.lock was called with 5 fields (protocolID, key, value, controller, tags)
766+
expect(localPushDrop.lock).toHaveBeenCalled()
767+
const lockArgs = (localPushDrop.lock as jest.Mock).mock.calls[0]
768+
const fields = lockArgs[0]
769+
expect(Array.isArray(fields)).toBe(true)
770+
expect(fields.length).toBe(5)
771+
772+
// Restore original implementation
773+
;(MockPushDrop as any).mockImplementation = originalImpl
774+
})
775+
647776
it('updates existing token when one exists', async () => {
648777
// Mock the queryOverlay to return an entry with a token
649778
const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')

src/kvstore/kvStoreInterpreter.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export interface KVContext { key: string, protocolID: WalletProtocol }
1010
/**
1111
* KVStore interpreter used by Historian.
1212
*
13-
* Validates the KVStore PushDrop tokens: [protocolID, key, value, controller, signature].
13+
* Validates the KVStore PushDrop tokens: [protocolID, key, value, controller, signature] (old format)
14+
* or [protocolID, key, value, controller, tags, signature] (new format).
1415
* Filters outputs by the provided key in the interpreter context.
1516
* Produces the plaintext value for matching outputs; returns undefined otherwise.
1617
*
@@ -30,8 +31,12 @@ export const kvStoreInterpreter: InterpreterFunction<string, KVContext> = async
3031
// Decode the KVStore token
3132
const decoded = PushDrop.decode(output.lockingScript)
3233

33-
// Validate KVStore token format (must have 5 fields: [protocolID, key, value, controller, signature])
34-
if (decoded.fields.length !== Object.keys(kvProtocol).length) return undefined
34+
// Support backwards compatibility: old format without tags, new format with tags
35+
const expectedFieldCount = Object.keys(kvProtocol).length
36+
const hasTagsField = decoded.fields.length === expectedFieldCount
37+
const isOldFormat = decoded.fields.length === expectedFieldCount - 1
38+
39+
if (!isOldFormat && !hasTagsField) return undefined
3540

3641
// Only return values for the given key and protocolID
3742
const key = Utils.toUTF8(decoded.fields[kvProtocol.key])

src/kvstore/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export interface KVStoreQuery {
4141
key?: string
4242
controller?: PubKeyHex
4343
protocolID?: WalletProtocol
44+
tags?: string[]
45+
/**
46+
* Controls tag matching behavior when tags are specified.
47+
* - 'all': Requires all specified tags to be present (default)
48+
* - 'any': Requires at least one of the specified tags to be present
49+
*/
50+
tagQueryMode?: 'all' | 'any'
4451
limit?: number
4552
skip?: number
4653
sortOrder?: 'asc' | 'desc'
@@ -63,6 +70,7 @@ export interface KVStoreSetOptions {
6370
tokenSetDescription?: string
6471
tokenUpdateDescription?: string
6572
tokenAmount?: number
73+
tags?: string[]
6674
}
6775

6876
export interface KVStoreRemoveOptions {
@@ -78,6 +86,7 @@ export interface KVStoreEntry {
7886
value: string
7987
controller: PubKeyHex
8088
protocolID: WalletProtocol
89+
tags?: string[]
8190
token?: KVStoreToken
8291
history?: string[]
8392
}
@@ -110,5 +119,6 @@ export const kvProtocol = {
110119
key: 1,
111120
value: 2,
112121
controller: 3,
113-
signature: 4
122+
tags: 4,
123+
signature: 5 // Note: signature moves to position 5 when tags are present
114124
}

0 commit comments

Comments
 (0)