KISAM files — create, write and read (worked example)

A KISAM file is KCML's native keyed-ISAM data file: fixed-length records indexed by one or more key paths. This example puts the whole lifecycle on a single tabbed form — the first tab creates a KISAM file and writes records to it from input fields (one field per column), and the second tab reads every record back into a grid. For a true end-to-end picture, the page finishes by reading the very same file from a short Python script — KISAM records are just fixed-length bytes, so any language can read them.

The Write tab: ID, Name and City input fields mapping to record columns, an Add record button, and a status line reading 'Wrote record [00004] Dan Brook, Oxford (4 records total)'.

The Read tab: a grid listing all four records read back from the KISAM file — 00001 Alice Walker London, 00002 Bob Stone Leeds, 00003 Carol Nash Bristol, 00004 Dan Brook Oxford — with '4 records loaded'.

Verified by execution on KCML 06.00.88 (KClient direct mode).

What it demonstrates

The complete program

The whole example — save it as demo_kisam.src and launch it with the GUI client in direct mode (kclient -d demo_kisam.src). Adjust file$ to a writable path on your machine; the directory must already exist. The annotated walkthrough of the key parts follows below.

01000 REM demo_kisam - create a KISAM file, write records from input fields, read them back
    : REM Tab 1 writes records (one field per column); Tab 2 reads them all into a grid.
    : DIM result, ki_status, handle, ki_sym, ki_dataptr$6, ki_key$64
    : DIM keyspec$(1)33, nrows, reclen, ftype, dictfile$2, file$120, rec$41
    : DIM recs_written, cnt, gr, sv_i, msg$120
    : DIM in_id$5, in_name$20, in_city$15
    : DIM seed_id$(3)5, seed_name$(3)20, seed_city$(3)15
    : file$ = "C:/kisam_data/PEOPLE"
    : nrows = 200
    : reclen = 41
    : ftype = 6
    : dictfile$ = " "
    : seed_id$(1) = "00001" : seed_name$(1) = "Alice Walker" : seed_city$(1) = "London"
    : seed_id$(2) = "00002" : seed_name$(2) = "Bob Stone" : seed_city$(2) = "Leeds"
    : seed_id$(3) = "00003" : seed_name$(3) = "Carol Nash" : seed_city$(3) = "Bristol"
01010 - DEFFORM KisamDemo()=\
       {.form,.form$,.Style=0x50c000c4,.Width=440,.Height=320,.Text$="KISAM - create, write and read",.Id=1024},\
       {.tabMain,.tabbed$,.Style=0x500100c0,.Left=8,.Top=8,.Width=424,.Height=270,.Id=1000,\
            .TabWrite={.Text$="1. Write"},\
            .TabRead={.Text$="2. Read"}},\
       {.lblHint,.static$,.Style=0x50000000,.Left=20,.Top=44,.Width=395,.Height=22,.Text$="Each field below maps to a column in the record. Type values and click Add.",.Id=2000,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
       {.lblId,.static$,.Style=0x50000000,.Left=20,.Top=78,.Width=70,.Height=12,.Text$="ID (key):",.Id=2001,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
       {.edtId,.edit$,.Style=0x50810080,.Left=95,.Top=76,.Width=90,.Height=13,.Id=2002,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
       {.lblName,.static$,.Style=0x50000000,.Left=20,.Top=98,.Width=70,.Height=12,.Text$="Name:",.Id=2003,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
       {.edtName,.edit$,.Style=0x50810080,.Left=95,.Top=96,.Width=200,.Height=13,.Id=2004,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
       {.lblCity,.static$,.Style=0x50000000,.Left=20,.Top=118,.Width=70,.Height=12,.Text$="City:",.Id=2005,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
       {.edtCity,.edit$,.Style=0x50810080,.Left=95,.Top=116,.Width=150,.Height=13,.Id=2006,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
       {.btnAdd,.button$,.Style=0x50010000,.Left=95,.Top=140,.Width=110,.Height=15,.Text$="Add record",.Id=2007,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
       {.lblWrote,.static$,.Style=0x50000000,.Left=20,.Top=170,.Width=395,.Height=40,.Text$="",.Id=2008,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
       {.lblReadHint,.static$,.Style=0x50000000,.Left=20,.Top=44,.Width=395,.Height=12,.Text$="Every record read back from the KISAM file:",.Id=3000,.Parent=.tabMain,.Page=.TabRead,.Font=.UI},\
       {.grid,.KCMLgrid$,.Style=0x50013030,.Left=20,.Top=60,.Width=395,.Height=170,.Id=3001,.Rows=21,.Cols=3,.FixedRows=1,.Parent=.tabMain,.Page=.TabRead,.Font=.Mono},\
       {.btnLoad,.button$,.Style=0x50010000,.Left=20,.Top=236,.Width=130,.Height=15,.Text$="Reload from file",.Id=3002,.Parent=.tabMain,.Page=.TabRead,.Font=.UI},\
       {.lblCount,.static$,.Style=0x50000000,.Left=160,.Top=238,.Width=255,.Height=12,.Text$="",.Id=3003,.Parent=.tabMain,.Page=.TabRead,.Font=.UI},\
       {.btnClose,.button$,.Style=0x50010001,.Left=352,.Top=283,.Width=80,.Height=15,.Text$="Close",.Id=1,.Font=.UI},\
       {.paneStatus,.status$,.Width=440,.Style=0x50000000,.Text$="Ready"},\
       {.UI,.dlgfont$,.Name$="Segoe UI",.Size=10},\
       {.Mono,.dlgfont$,.Name$="Consolas",.Size=10},\
       {.clrHdr,.color$,.Red=224,.Green=224,.Blue=224}
    :     + DEFEVENT KisamDemo.Enter()
    :         keyspec$(1) = ALL(HEX(00))
    :         STR(keyspec$(1), 1, 1) = "N"
    :         STR(keyspec$(1), 2, 2) = BIN(2, 2)
    :         STR(keyspec$(1), 4, 1) = BIN(5)
    :         CALL KI_ALLOC_HANDLE 0, 1 TO handle, ki_status
    :         CALL KI_CREATE handle, file$, dictfile$, nrows, reclen, SYM(keyspec$()), 4, 50, ftype TO ki_status
    :         recs_written = 0
    :         FOR sv_i = 1 TO 3
    :             rec$ = ALL(HEX(20))
    :             STR(rec$, 1, 1) = HEX(00)
    :             STR(rec$, 2, 5) = seed_id$(sv_i)
    :             STR(rec$, 7, 20) = seed_name$(sv_i)
    :             STR(rec$, 27, 15) = seed_city$(sv_i)
    :             ki_sym = SYM(rec$)
    :             CALL KI_WRITE handle, ki_sym, 0 TO ki_status, ki_dataptr$
    :             IF ki_status == 0 THEN recs_written = recs_written + 1
    :         NEXT sv_i
    :         CALL KI_CLOSE handle TO ki_status
    :         .edtId.Text$ = "00004"
    :         .edtName.Text$ = "Dan Brook"
    :         .edtCity.Text$ = "Oxford"
    :         .lblWrote.Text$ = $PRINTF("Created the file and seeded %d records. Add more, then open the Read tab.", recs_written)
    :         .paneStatus.Text$ = $PRINTF("KISAM file created - %d records", recs_written)
    :     END EVENT
    :     + DEFEVENT KisamDemo.btnAdd.Click()
    :         in_id$ = RTRIM(.edtId.Text$)
    :         in_name$ = RTRIM(.edtName.Text$)
    :         in_city$ = RTRIM(.edtCity.Text$)
    :         CALL KI_ALLOC_HANDLE 0, 1 TO handle, ki_status
    :         CALL KI_OPEN handle, file$, "W" TO ki_status
    :         rec$ = ALL(HEX(20))
    :         STR(rec$, 1, 1) = HEX(00)
    :         STR(rec$, 2, 5) = in_id$
    :         STR(rec$, 7, 20) = in_name$
    :         STR(rec$, 27, 15) = in_city$
    :         ki_sym = SYM(rec$)
    :         CALL KI_WRITE handle, ki_sym, 0 TO ki_status, ki_dataptr$
    :         CALL KI_CLOSE handle TO ki_status
    :         IF ki_status == 0 THEN recs_written = recs_written + 1
    :         IF ki_status == 0 THEN .lblWrote.Text$ = $PRINTF("Wrote record [%s] %s, %s  (%d records total).", in_id$, in_name$, in_city$, recs_written)
    :         IF ki_status <> 0 THEN .lblWrote.Text$ = $PRINTF("Write failed - status %d (duplicate key?)", ki_status)
    :         .paneStatus.Text$ = $PRINTF("btnAdd.Click - write status %d", ki_status)
    :     END EVENT
    :     + DEFEVENT KisamDemo.tabMain.TabRead.Enter()
    :         GOSUB 2000
    :     END EVENT
    :     + DEFEVENT KisamDemo.btnLoad.Click()
    :         GOSUB 2000
    :     END EVENT
    : FORM END KisamDemo
01020 result = KisamDemo.Open()
    : $END
02000 REM load_grid subroutine - read every record into the grid
    : .grid.Cell(0,1).ColWidth = 70
    : .grid.Cell(0,2).ColWidth = 200
    : .grid.Cell(0,3).ColWidth = 120
    : .grid.Cell(1,1).Text$ = "ID"
    : .grid.Cell(1,2).Text$ = "Name"
    : .grid.Cell(1,3).Text$ = "City"
    : FOR gr = 1 TO 3
    :     .grid.Cell(1,gr).BackColor = &.clrHdr
    : NEXT gr
    : FOR gr = 2 TO 20
    :     .grid.Cell(gr,1).Text$ = ""
    :     .grid.Cell(gr,2).Text$ = ""
    :     .grid.Cell(gr,3).Text$ = ""
    : NEXT gr
    : CALL KI_ALLOC_HANDLE 0, 1 TO handle, ki_status
    : CALL KI_OPEN handle, file$, "R" TO ki_status
    : CALL KI_START_BEG handle, 1 TO ki_status
    : cnt = 0
    : gr = 2
    : rec$ = ALL(HEX(20))
    : ki_sym = SYM(rec$)
    : REPEAT
    :     CALL KI_READ_NEXT handle, 1, ki_sym TO ki_status, ki_dataptr$, ki_key$
    :     IF ki_status == 0 THEN DO
    :         .grid.Cell(gr,1).Text$ = RTRIM(STR(rec$, 2, 5))
    :         .grid.Cell(gr,2).Text$ = RTRIM(STR(rec$, 7, 20))
    :         .grid.Cell(gr,3).Text$ = RTRIM(STR(rec$, 27, 15))
    :         cnt = cnt + 1
    :         gr = gr + 1
    :     END DO
    : UNTIL ki_status <> 0 OR gr > 20
    : CALL KI_CLOSE handle TO ki_status
    : .lblCount.Text$ = $PRINTF("%d records loaded", cnt)
    : .paneStatus.Text$ = $PRINTF("load_grid - %d records", cnt)
    : RETURN

The record layout

Each record is a fixed 41-byte string. Byte 1 is reserved by KISAM; the columns start at byte 2:

Bytes Column Notes
1 (status) KISAM control byte — HEX(00) = live record
2–6 id the key (5 chars)
7–26 name 20 chars
27–41 city 15 chars

1. Creating the file

The key specification is a 33-byte string in an array, built byte-by-byte: a duplicates flag ("N" = no duplicates) followed by one 4-byte segment (2-byte start, 1-byte length, 1-byte reserved). Here the key is the 5-byte id starting at byte 2:

: DIM keyspec$(1)33, dictfile$2, nrows, reclen, ftype
: keyspec$(1) = ALL(HEX(00))
: STR(keyspec$(1), 1, 1) = "N"          : REM no duplicate keys
: STR(keyspec$(1), 2, 2) = BIN(2, 2)    : REM key starts at byte 2
: STR(keyspec$(1), 4, 1) = BIN(5)       : REM key is 5 bytes long
: nrows = 200 : reclen = 41 : ftype = 6 : dictfile$ = " "
: CALL KI_ALLOC_HANDLE 0, 1 TO handle, ki_status
: CALL KI_CREATE handle, file$, dictfile$, nrows, reclen, SYM(keyspec$()), 4, 50, ftype TO ki_status

KI_CREATE both creates and opens the file. Its argument order in KCML 6.x is handle, file$, dictfile$, maxrows, rowlen, SYM(keyspec$()), blocklen, packing, type. dictfile$ (a data-dictionary name) is unused — pass a single space. blocklen is the index block size in 256-byte units (4) and packing is the index packing percentage (50).

2. Writing records

The Add button copies the three input fields into their byte ranges and calls KI_WRITE. Byte 1 is left as the status byte (KI_WRITE forces it to HEX(00)):

: CALL KI_OPEN handle, file$, "W" TO ki_status
: rec$ = ALL(HEX(20))                    : REM space-fill the record
: STR(rec$, 1, 1) = HEX(00)              : REM status byte
: STR(rec$, 2, 5)  = RTRIM(.edtId.Text$)
: STR(rec$, 7, 20) = RTRIM(.edtName.Text$)
: STR(rec$, 27, 15)= RTRIM(.edtCity.Text$)
: ki_sym = SYM(rec$)
: CALL KI_WRITE handle, ki_sym, 0 TO ki_status, ki_dataptr$
: CALL KI_CLOSE handle TO ki_status

The third KI_WRITE argument (0) selects key path 1. A status of 0 means the record was inserted; a non-zero status on a fresh key usually means a duplicate key.

3. Reading every record back

The Read tab opens the file read-only, positions at the start of key path 1 with KI_START_BEG, then loops KI_READ_NEXT until it returns non-zero (end of file). Each record is sliced back into columns by the same byte offsets and dropped into a grid row:

: CALL KI_OPEN handle, file$, "R" TO ki_status
: CALL KI_START_BEG handle, 1 TO ki_status
: gr = 2                                 : REM grid data starts at row 2
: rec$ = ALL(HEX(20))
: ki_sym = SYM(rec$)
: REPEAT
:   CALL KI_READ_NEXT handle, 1, ki_sym TO ki_status, ki_dataptr$, ki_key$
:   IF ki_status == 0 THEN DO
:     .grid.Cell(gr,1).Text$ = RTRIM(STR(rec$, 2, 5))
:     .grid.Cell(gr,2).Text$ = RTRIM(STR(rec$, 7, 20))
:     .grid.Cell(gr,3).Text$ = RTRIM(STR(rec$, 27, 15))
:     gr = gr + 1
:   END DO
: UNTIL ki_status <> 0 OR gr > 20
: CALL KI_CLOSE handle TO ki_status

Pre-assign ki_sym = SYM(rec$) before the loop — do not pass SYM(rec$) inline to KI_READ_NEXT. KI_READ_NEXT also returns the record's raw key in ki_key$ and its row pointer in ki_dataptr$ (which must be 6 bytes).

Reading the same file from Python (end to end)

Because a KISAM record is just fixed-length bytes at known offsets, any language can read the file KCML wrote — no KCML required. The script below opens PEOPLE, reads the record length straight out of the type-6 header, finds the data records, and slices each field out by byte position. Run it after the form has created the file and you get the same three rows back:

file   : PEOPLE  (17920 bytes)
header : magic='K6'  reclen=41
records: 3 found

id     name                  city
--------------------------------------------
00001  Alice Walker          London
00002  Bob Stone             Leeds
00003  Carol Nash            Bristol
#!/usr/bin/env python3
# Reads the KISAM file produced by demo_kisam.src — without using KCML at all.
#
# A type-6 KISAM file is split into fixed-size blocks: some hold the index (the
# sorted keys, each with a pointer to a record), others hold the data — the
# records themselves, laid out back-to-back. We just want the data records.
#
# Record layout (must match the KCML program that wrote it). KCML strings are
# fixed-length and space-padded, so every record is exactly 41 bytes. Byte 1 is
# a control byte KISAM manages; the real columns start at byte 2:
#
#     offset(1-based)  length  column   notes
#           1            1     status   0x00 = a live record
#           2            5     id       the key, ASCII, space-padded
#           7           20     name     ASCII, space-padded
#          27           15     city     ASCII, space-padded

import struct                      # struct: unpack fixed-width binary fields
import sys
from pathlib import Path

# demo_kisam.src writes to <repo>/example_code/forms/kisam_data/PEOPLE.
HERE = Path(__file__).resolve().parent
KISAM_FILE = HERE.parent / "forms" / "kisam_data" / "PEOPLE"

# The record layout as (column-name, 1-based-start, length) — the offsets read
# exactly like the KCML STR(rec$, start, len) calls that wrote the fields.
FIELDS = [
    ("id",    2,  5),
    ("name",  7, 20),
    ("city", 27, 15),
]


def read_header(buf):
    """Pull the two things we need out of the KISAM header.

    A type-6 header begins with the ASCII magic "K6" (bytes 0-1) and stores the
    record length as a 2-byte BIG-ENDIAN integer at bytes 4-5. We read the length
    from the file rather than hard-coding 41, so the reader survives layout
    changes.  ">H" = big-endian (>) unsigned 16-bit (H).
    """
    magic = buf[0:2].decode("ascii", "replace")     # -> "K6"
    (reclen,) = struct.unpack_from(">H", buf, 4)     # read 2 bytes at offset 4
    return magic, reclen


def looks_like_record(buf, off, reclen):
    """True if the bytes at `off` look like a real, live data record.

    The index block holds the same key values (e.g. "00001") but followed by
    4-byte row pointers, not name/city text — so we must not mistake those for
    records. A genuine record has a 0x00 status byte and printable ASCII in the
    key and name (an index entry's "name" area is pointer bytes / nulls).
    """
    if off + reclen > len(buf):
        return False
    if buf[off] != 0x00:                             # byte 1 must be the live flag
        return False
    key  = buf[off + 1:off + 6]                      # bytes 2-6  (id)
    name = buf[off + 6:off + 26]                     # bytes 7-26 (name)
    printable = lambda bs: all(0x20 <= b <= 0x7E for b in bs)
    return printable(key) and key[0:1] != b" " and printable(name)


def find_records(buf, reclen):
    """Yield each record as a `reclen`-byte slice.

    Walk the file until we hit something that looks like a record; since records
    sit back-to-back in a data block, jump forward by `reclen` to the next one.
    When a slot stops looking like a record (we've run past the last one), go
    back to scanning byte-by-byte. No need to know where the data block starts.
    """
    off = 0
    while off + reclen <= len(buf):
        if looks_like_record(buf, off, reclen):
            yield buf[off:off + reclen]
            off += reclen
        else:
            off += 1


def decode_record(rec):
    """Turn one raw record into {column: text}. Each field is a fixed-width
    ASCII slice; KCML pads on the right with spaces, so we strip them off."""
    out = {}
    for name, start, length in FIELDS:
        raw = rec[start - 1:start - 1 + length]      # 1-based offset -> 0-based slice
        out[name] = raw.decode("ascii", "replace").rstrip()
    return out


def main():
    if not KISAM_FILE.exists():
        sys.exit(f"KISAM file not found: {KISAM_FILE} (run demo_kisam.src first)")

    buf = KISAM_FILE.read_bytes()
    magic, reclen = read_header(buf)
    print(f"file   : {KISAM_FILE.name}  ({len(buf)} bytes)")
    print(f"header : magic={magic!r}  reclen={reclen}")
    if magic != "K6":
        sys.exit("not a type-6 KISAM file (magic != 'K6')")

    records = [decode_record(r) for r in find_records(buf, reclen)]
    print(f"records: {len(records)} found\n")

    print(f"{'id':<7}{'name':<22}{'city'}")
    print("-" * 44)
    for r in records:
        print(f"{r['id']:<7}{r['name']:<22}{r['city']}")


if __name__ == "__main__":
    main()

The fields here are all text, so decoding is just "slice and strip". KISAM records can of course hold binary integers, packed-decimal numbers, dates and more — the companion script read_kisam_types.py in the repo decodes a record containing one field of every KCML data type.

Gotchas worth knowing

See also