Skip to content

Commit 038c3e2

Browse files
committed
[Wiegand] Upload parquet files on R2 using S3 SDK
1 parent 504e75f commit 038c3e2

8 files changed

Lines changed: 1325 additions & 28 deletions

File tree

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"clean:d1": "rm -rf .wrangler/state/v3/d1",
2828
"storybook": "storybook dev -p 6006",
2929
"build-storybook": "storybook build",
30-
"script:generate-wiegand-parquet": "tsx --max-old-space-size=4096 scripts/generate-wiegand-parquet/index.ts"
30+
"script:generate-wiegand-parquet": "dotenv -e .dev.vars -- tsx --max-old-space-size=4096 scripts/generate-wiegand-parquet/index.ts"
3131
},
3232
"dependencies": {
3333
"@codemirror/lang-json": "6.0.2",
@@ -51,6 +51,8 @@
5151
"vue-router": "5.0.3"
5252
},
5353
"devDependencies": {
54+
"@aws-sdk/client-s3": "3.1004.0",
55+
"@aws-sdk/lib-storage": "3.1004.0",
5456
"@biomejs/biome": "2.4.6",
5557
"@cloudflare/vite-plugin": "1.26.1",
5658
"@storybook/addon-docs": "10.2.16",

pnpm-lock.yaml

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

scripts/generate-wiegand-parquet/cli.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,14 @@ Usage:
5252
Options:
5353
-c, --country <code> Country code to process (${AVAILABLE_CODES})
5454
-u, --upload Upload generated files to Cloudflare R2
55-
-l, --local Upload to local Miniflare R2 instead of remote
55+
-l, --local Upload to local Miniflare R2 (via wrangler) instead of remote (via S3 API)
5656
-h, --help Show this help message
5757
58+
Environment variables (required for --upload without --local):
59+
TOOLBOX_CF_ACCOUNT_ID Your Cloudflare account ID
60+
TOOLBOX_CF_R2_ACCESS_KEY_ID R2 API token access key
61+
TOOLBOX_CF_R2_SECRET_ACCESS_KEY R2 API token secret key
62+
5863
Examples:
5964
pnpm run script:generate-wiegand-parquet -- --country LU
6065
pnpm run script:generate-wiegand-parquet -- -c BE --upload

scripts/generate-wiegand-parquet/countries.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ function* streamLuxembourgSlice(sliceIndex: number): Generator<string> {
224224
}
225225

226226
// ── Netherlands ──────────────────────────────────────────────────────
227-
// Sidecodes 69:
227+
// Sidecodes 6-9:
228228
// 6: 99XXXX (45,697,600) 7: XX99XX (45,697,600)
229229
// 8: 9XXX99 (17,576,000) 9: X999XX (17,576,000)
230230
// Total: 126,547,200 — Slices: 26

scripts/generate-wiegand-parquet/upload.ts

Lines changed: 85 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { execFile } from 'node:child_process'
2-
import { statSync } from 'node:fs'
2+
import { createReadStream, statSync } from 'node:fs'
33
import path from 'node:path'
44
import { promisify } from 'node:util'
5+
import { S3Client } from '@aws-sdk/client-s3'
6+
import { Upload } from '@aws-sdk/lib-storage'
57
import { formatFileSize } from '../../shared/utils/formatFileSize.js'
68
import { logger } from '../../shared/utils/logger.js'
79

@@ -10,43 +12,106 @@ const execFileAsync = promisify(execFile)
1012
const R2_BUCKET = 'anpr-wiegand26-registry'
1113
const CONCURRENCY = 6
1214

15+
function createS3Client(): S3Client {
16+
const accountId = process.env.TOOLBOX_CF_ACCOUNT_ID
17+
const accessKeyId = process.env.TOOLBOX_CF_R2_ACCESS_KEY_ID
18+
const secretAccessKey = process.env.TOOLBOX_CF_R2_SECRET_ACCESS_KEY
19+
20+
if (!accountId || !accessKeyId || !secretAccessKey) {
21+
throw new Error(
22+
'Missing R2 credentials. Set TOOLBOX_CF_ACCOUNT_ID, TOOLBOX_CF_R2_ACCESS_KEY_ID, and TOOLBOX_CF_R2_SECRET_ACCESS_KEY environment variables.\n'
23+
+ 'Create an R2 API token at: https://dash.cloudflare.com → R2 → Manage R2 API Tokens',
24+
)
25+
}
26+
27+
return new S3Client({
28+
region: 'auto',
29+
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
30+
credentials: { accessKeyId, secretAccessKey },
31+
})
32+
}
33+
1334
export async function uploadToR2(files: string[], countryCode: string, local: boolean): Promise<void> {
14-
const target = local ? 'local' : 'remote'
15-
logger.info('=== Uploading %d file(s) to %s R2 bucket %s (%d concurrent) ===', files.length, target, R2_BUCKET, CONCURRENCY)
35+
const target = local ? 'local (wrangler)' : 'remote (S3 API)'
36+
if (local) {
37+
logger.info('=== Uploading %d file(s) to %s R2 bucket %s (sequential) ===', files.length, target, R2_BUCKET)
38+
} else {
39+
logger.info('=== Uploading %d file(s) to %s R2 bucket %s (%d concurrent) ===', files.length, target, R2_BUCKET, CONCURRENCY)
40+
}
1641

1742
let completed = 0
1843

1944
async function uploadWithCounter(filepath: string): Promise<void> {
20-
await uploadFile(filepath, countryCode, local)
45+
if (local) {
46+
await uploadFileLocal(filepath, countryCode)
47+
} else {
48+
await uploadFileRemote(filepath, countryCode)
49+
}
2150
completed++
2251
logger.info(' [%d/%d] Uploaded %s (%s)', completed, files.length, path.basename(filepath), formatFileSize(statSync(filepath).size))
2352
}
2453

25-
const queue = [...files]
26-
const running = new Set<Promise<void>>()
54+
if (local) {
55+
for (const filepath of files) {
56+
await uploadWithCounter(filepath)
57+
}
58+
} else {
59+
const queue = [...files]
60+
const running = new Set<Promise<void>>()
2761

28-
while (queue.length > 0 || running.size > 0) {
29-
while (running.size < CONCURRENCY && queue.length > 0) {
30-
const filepath = queue.shift()
31-
if (filepath === undefined) {
32-
break
62+
while (queue.length > 0 || running.size > 0) {
63+
while (running.size < CONCURRENCY && queue.length > 0) {
64+
const filepath = queue.shift()
65+
if (filepath === undefined) {
66+
break
67+
}
68+
const promise = uploadWithCounter(filepath).then(() => {
69+
running.delete(promise)
70+
})
71+
running.add(promise)
72+
}
73+
if (running.size > 0) {
74+
await Promise.race(running)
3375
}
34-
const promise = uploadWithCounter(filepath).then(() => {
35-
running.delete(promise)
36-
})
37-
running.add(promise)
38-
}
39-
if (running.size > 0) {
40-
await Promise.race(running)
4176
}
4277
}
4378

4479
logger.info('=== Upload complete ===')
4580
}
4681

47-
async function uploadFile(filepath: string, countryCode: string, local: boolean): Promise<void> {
82+
let s3Client: S3Client | null = null
83+
84+
async function uploadFileRemote(filepath: string, countryCode: string): Promise<void> {
85+
if (!s3Client) {
86+
s3Client = createS3Client()
87+
}
88+
89+
const key = `${countryCode}/${path.basename(filepath)}`
90+
91+
try {
92+
const upload = new Upload({
93+
client: s3Client,
94+
params: {
95+
Bucket: R2_BUCKET,
96+
Key: key,
97+
Body: createReadStream(filepath),
98+
ContentType: 'application/octet-stream',
99+
},
100+
queueSize: 4,
101+
partSize: 10 * 1024 * 1024,
102+
})
103+
104+
await upload.done()
105+
} catch (error: unknown) {
106+
const message = error instanceof Error ? error.message : String(error)
107+
logger.error(' Failed to upload %s: %s', key, message)
108+
throw error
109+
}
110+
}
111+
112+
async function uploadFileLocal(filepath: string, countryCode: string): Promise<void> {
48113
const key = `${countryCode}/${path.basename(filepath)}`
49-
const args = ['r2', 'object', 'put', `${R2_BUCKET}/${key}`, '--file', filepath, ...(local ? ['--local'] : ['--remote'])]
114+
const args = ['r2', 'object', 'put', `${R2_BUCKET}/${key}`, '--file', filepath, '--local']
50115
const wranglerBin = path.resolve(import.meta.dirname, '../../node_modules/.bin/wrangler')
51116

52117
try {

shared/modules/wiegand/countries.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const WIEGAND_COUNTRIES: WiegandCountry[] = [
2929
{ code: 'LU', name: 'Luxembourg', pattern: 'NNNNNN' },
3030
{ code: 'LV', name: 'Latvia', pattern: 'LL-NNNN' },
3131
{ code: 'MT', name: 'Malta', pattern: 'LLL-NNN' },
32-
{ code: 'NL', name: 'Netherlands', pattern: 'Sidecodes 69' },
32+
{ code: 'NL', name: 'Netherlands', pattern: 'Sidecodes 6-9' },
3333
{ code: 'NO', name: 'Norway', pattern: 'LL-NNNNN' },
3434
{ code: 'PL', name: 'Poland', pattern: 'LL-NNNNN' },
3535
{ code: 'PT', name: 'Portugal', pattern: 'LL-NN-LL' },

src/modules/scim/ScimCompliance.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ function statusClass(status: Status): string {
153153
:key="item"
154154
class="tb-row tb-items-start tb-text-xs tb-text-secondary"
155155
>
156-
<span class="tb-text-muted tb-flex-shrink-0 tb-mt-1"></span>
156+
<span class="tb-text-muted tb-flex-shrink-0 tb-mt-1">-</span>
157157
<span>{{ item }}</span>
158158
</li>
159159
</ul>

src/modules/wiegand/logic.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Wiegand26Result } from 'anpr-wiegand'
12
import { decode26, decode64, encode26, encode64 } from 'anpr-wiegand'
23
import type { Decode26InputFormat, WiegandDecoded26, WiegandEncoded, WiegandMode, WiegandResult } from './types'
34

@@ -33,14 +34,13 @@ async function decode26WithFormat(input: string, format: Decode26InputFormat): P
3334
case 'decimal': {
3435
const asNumber = Number(input)
3536
if (!/^\d+$/.test(input) || !Number.isInteger(asNumber) || asNumber < 0 || asNumber > 16_777_215) {
36-
return { mode: 'error', error: 'Invalid decimal value (must be 016777215)' }
37+
return { mode: 'error', error: 'Invalid decimal value (must be 0-16777215)' }
3738
}
3839
const hex = asNumber.toString(16).toUpperCase()
3940
return { mode: 'decode26', decoded: (await decode26(hex)) ?? null }
4041
}
41-
case 'hex': {
42+
case 'hex':
4243
return { mode: 'decode26', decoded: (await decode26(input)) ?? null }
43-
}
4444
case 'plate': {
4545
const encoded = await encode26(input)
4646
if (!encoded) {

0 commit comments

Comments
 (0)